Skip to main content

provenant/parsers/
pylock_toml.rs

1use std::collections::{HashMap, HashSet, VecDeque};
2use std::path::Path;
3
4use crate::parser_warn as warn;
5use packageurl::PackageUrl;
6use regex::Regex;
7use serde_json::{Map as JsonMap, Value as JsonValue};
8use toml::Value as TomlValue;
9use toml::map::Map as TomlMap;
10
11use crate::models::{
12    DatasourceId, Dependency, Md5Digest, PackageData, PackageType, ResolvedPackage, Sha256Digest,
13    Sha512Digest,
14};
15use crate::parsers::python::read_toml_file;
16use crate::parsers::utils::{MAX_ITERATION_COUNT, truncate_field};
17
18use super::PackageParser;
19
20const FIELD_LOCK_VERSION: &str = "lock-version";
21const FIELD_CREATED_BY: &str = "created-by";
22const SUPPORTED_LOCK_VERSION: &str = "1.0";
23const FIELD_REQUIRES_PYTHON: &str = "requires-python";
24const FIELD_ENVIRONMENTS: &str = "environments";
25const FIELD_EXTRAS: &str = "extras";
26const FIELD_DEPENDENCY_GROUPS: &str = "dependency-groups";
27const FIELD_DEFAULT_GROUPS: &str = "default-groups";
28const FIELD_PACKAGES: &str = "packages";
29const FIELD_NAME: &str = "name";
30const FIELD_VERSION: &str = "version";
31const FIELD_MARKER: &str = "marker";
32const FIELD_DEPENDENCIES: &str = "dependencies";
33const FIELD_INDEX: &str = "index";
34const FIELD_VCS: &str = "vcs";
35const FIELD_DIRECTORY: &str = "directory";
36const FIELD_ARCHIVE: &str = "archive";
37const FIELD_SDIST: &str = "sdist";
38const FIELD_WHEELS: &str = "wheels";
39const FIELD_HASHES: &str = "hashes";
40const FIELD_TOOL: &str = "tool";
41const FIELD_ATTESTATION_IDENTITIES: &str = "attestation-identities";
42const MAX_RECURSION_DEPTH: usize = 50;
43
44pub struct PylockTomlParser;
45
46#[derive(Clone, Debug, Default)]
47struct MarkerClassification {
48    is_runtime: bool,
49    is_optional: bool,
50    scope: Option<String>,
51}
52
53struct DependencyAnalysisContext<'a> {
54    package_tables: &'a [&'a TomlMap<String, TomlValue>],
55    dependency_indices: &'a [Vec<usize>],
56    incoming_counts: &'a [usize],
57    root_classifications: &'a [MarkerClassification],
58    runtime_reachable: &'a HashSet<usize>,
59    optional_reachable: &'a HashSet<usize>,
60    scope_sets: &'a HashMap<String, HashSet<usize>>,
61}
62
63impl PackageParser for PylockTomlParser {
64    const PACKAGE_TYPE: PackageType = PackageType::Pypi;
65
66    fn is_match(path: &Path) -> bool {
67        let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else {
68            return false;
69        };
70
71        file_name == "pylock.toml"
72            || file_name
73                .strip_prefix("pylock.")
74                .and_then(|suffix| suffix.strip_suffix(".toml"))
75                .is_some_and(|middle| !middle.is_empty() && !middle.contains('.'))
76    }
77
78    fn extract_packages(path: &Path) -> Vec<PackageData> {
79        let toml_content = match read_toml_file(path) {
80            Ok(content) => content,
81            Err(e) => {
82                warn!("Failed to read pylock.toml at {:?}: {}", path, e);
83                return vec![default_package_data()];
84            }
85        };
86
87        vec![parse_pylock_toml(&toml_content)]
88    }
89}
90
91fn parse_pylock_toml(toml_content: &TomlValue) -> PackageData {
92    let lock_version = toml_content
93        .get(FIELD_LOCK_VERSION)
94        .and_then(TomlValue::as_str);
95    if lock_version != Some(SUPPORTED_LOCK_VERSION) {
96        warn!(
97            "Invalid pylock.toml: missing or unsupported lock-version {:?}",
98            lock_version
99        );
100        return default_package_data();
101    }
102
103    let created_by = toml_content
104        .get(FIELD_CREATED_BY)
105        .and_then(TomlValue::as_str);
106    if created_by.is_none() {
107        warn!("Invalid pylock.toml: missing required created-by field");
108        return default_package_data();
109    }
110
111    let Some(package_values) = toml_content
112        .get(FIELD_PACKAGES)
113        .and_then(TomlValue::as_array)
114    else {
115        warn!("Invalid pylock.toml: missing required packages array");
116        return default_package_data();
117    };
118
119    let package_tables: Vec<&TomlMap<String, TomlValue>> = package_values
120        .iter()
121        .take(MAX_ITERATION_COUNT)
122        .filter_map(TomlValue::as_table)
123        .collect();
124    if package_tables.is_empty() {
125        warn!("Invalid pylock.toml: packages array does not contain package tables");
126        return default_package_data();
127    }
128
129    let dependency_indices = build_dependency_indices(&package_tables);
130    let incoming_counts = build_incoming_counts(package_tables.len(), &dependency_indices);
131    let default_groups = extract_string_set(toml_content, FIELD_DEFAULT_GROUPS);
132
133    let root_classifications: Vec<MarkerClassification> = package_tables
134        .iter()
135        .enumerate()
136        .map(|(index, table)| {
137            if incoming_counts[index] == 0 {
138                classify_marker(
139                    table.get(FIELD_MARKER).and_then(TomlValue::as_str),
140                    &default_groups,
141                )
142            } else {
143                MarkerClassification::default()
144            }
145        })
146        .collect();
147
148    let runtime_roots: Vec<usize> = root_classifications
149        .iter()
150        .enumerate()
151        .filter_map(|(index, info)| {
152            (incoming_counts[index] == 0 && info.is_runtime).then_some(index)
153        })
154        .collect();
155    let optional_roots: Vec<usize> = root_classifications
156        .iter()
157        .enumerate()
158        .filter_map(|(index, info)| {
159            (incoming_counts[index] == 0 && info.is_optional).then_some(index)
160        })
161        .collect();
162
163    let runtime_reachable = collect_reachable_indices(&dependency_indices, &runtime_roots);
164    let optional_reachable = collect_reachable_indices(&dependency_indices, &optional_roots);
165
166    let mut scope_sets: HashMap<String, HashSet<usize>> = HashMap::new();
167    for (index, info) in root_classifications.iter().enumerate() {
168        if incoming_counts[index] != 0 {
169            continue;
170        }
171
172        if let Some(scope) = info.scope.as_ref() {
173            scope_sets.insert(
174                scope.clone(),
175                collect_reachable_indices(&dependency_indices, &[index]),
176            );
177        }
178    }
179
180    let analysis = DependencyAnalysisContext {
181        package_tables: &package_tables,
182        dependency_indices: &dependency_indices,
183        incoming_counts: &incoming_counts,
184        root_classifications: &root_classifications,
185        runtime_reachable: &runtime_reachable,
186        optional_reachable: &optional_reachable,
187        scope_sets: &scope_sets,
188    };
189
190    let mut package_data = default_package_data();
191    package_data.extra_data = build_lock_extra_data(toml_content);
192    package_data.dependencies = package_tables
193        .iter()
194        .enumerate()
195        .filter_map(|(index, package_table)| {
196            build_top_level_dependency(index, package_table, &analysis)
197        })
198        .collect();
199
200    package_data
201}
202
203fn build_top_level_dependency(
204    index: usize,
205    package_table: &TomlMap<String, TomlValue>,
206    analysis: &DependencyAnalysisContext<'_>,
207) -> Option<Dependency> {
208    let name = normalized_package_name(package_table)?;
209    let version = package_version(package_table);
210    let direct = analysis
211        .incoming_counts
212        .get(index)
213        .copied()
214        .unwrap_or_default()
215        == 0;
216
217    let (is_runtime, is_optional, scope) = if direct {
218        let classification = analysis
219            .root_classifications
220            .get(index)
221            .cloned()
222            .unwrap_or_default();
223        (
224            classification.is_runtime,
225            classification.is_optional,
226            classification.scope,
227        )
228    } else {
229        let is_runtime = analysis.runtime_reachable.contains(&index);
230        let is_optional = !is_runtime && analysis.optional_reachable.contains(&index);
231        let scope = scope_for_index(analysis.scope_sets, index);
232        (is_runtime, is_optional, scope)
233    };
234
235    Some(Dependency {
236        purl: create_pypi_purl(&name, version.as_deref()),
237        extracted_requirement: None,
238        scope: scope.map(truncate_field),
239        is_runtime: Some(is_runtime),
240        is_optional: Some(is_optional),
241        is_pinned: Some(is_package_pinned(package_table)),
242        is_direct: Some(direct),
243        resolved_package: Some(Box::new(build_resolved_package(
244            package_table,
245            analysis.package_tables,
246            analysis
247                .dependency_indices
248                .get(index)
249                .map(Vec::as_slice)
250                .unwrap_or(&[]),
251        ))),
252        extra_data: build_package_extra_data(package_table),
253    })
254}
255
256fn build_resolved_package(
257    package_table: &TomlMap<String, TomlValue>,
258    package_tables: &[&TomlMap<String, TomlValue>],
259    dependency_indices: &[usize],
260) -> ResolvedPackage {
261    let name = normalized_package_name(package_table).unwrap_or_default();
262    let version = package_version(package_table).unwrap_or_default();
263    let (_, repository_download_url, api_data_url, purl) = build_pypi_urls(
264        Some(&name),
265        (!version.is_empty()).then_some(version.as_str()),
266    );
267    let repository_homepage_url = Some(format!("https://pypi.org/project/{}", name));
268    let (download_url, sha256, sha512, md5) = extract_artifact_metadata(package_table);
269
270    ResolvedPackage {
271        primary_language: Some("Python".to_string()),
272        download_url,
273        sha1: None,
274        sha256: sha256.and_then(|h| Sha256Digest::from_hex(&h).ok()),
275        sha512: sha512.and_then(|h| Sha512Digest::from_hex(&h).ok()),
276        md5: md5.and_then(|h| Md5Digest::from_hex(&h).ok()),
277        is_virtual: false,
278        extra_data: build_package_extra_data(package_table),
279        dependencies: dependency_indices
280            .iter()
281            .filter_map(|child_index| package_tables.get(*child_index))
282            .filter_map(|child| build_resolved_dependency(child))
283            .collect(),
284        repository_homepage_url,
285        repository_download_url,
286        api_data_url,
287        datasource_id: Some(DatasourceId::PypiPylockToml),
288        purl,
289        ..ResolvedPackage::new(PylockTomlParser::PACKAGE_TYPE, String::new(), name, version)
290    }
291}
292
293fn build_resolved_dependency(package_table: &TomlMap<String, TomlValue>) -> Option<Dependency> {
294    let name = normalized_package_name(package_table)?;
295    let version = package_version(package_table);
296
297    Some(Dependency {
298        purl: create_pypi_purl(&name, version.as_deref()),
299        extracted_requirement: None,
300        scope: None,
301        is_runtime: None,
302        is_optional: None,
303        is_pinned: Some(is_package_pinned(package_table)),
304        is_direct: Some(true),
305        resolved_package: None,
306        extra_data: build_package_extra_data(package_table),
307    })
308}
309
310fn build_dependency_indices(package_tables: &[&TomlMap<String, TomlValue>]) -> Vec<Vec<usize>> {
311    let mut total_iterations: usize = 0;
312    package_tables
313        .iter()
314        .map(|package_table| {
315            package_table
316                .get(FIELD_DEPENDENCIES)
317                .and_then(TomlValue::as_array)
318                .into_iter()
319                .flatten()
320                .filter_map(TomlValue::as_table)
321                .flat_map(|reference| {
322                    if total_iterations >= MAX_ITERATION_COUNT {
323                        return Vec::new();
324                    }
325                    total_iterations += 1;
326                    resolve_dependency_reference_indices(package_tables, reference)
327                })
328                .collect()
329        })
330        .collect()
331}
332
333fn resolve_dependency_reference_indices(
334    package_tables: &[&TomlMap<String, TomlValue>],
335    reference: &TomlMap<String, TomlValue>,
336) -> Vec<usize> {
337    let matches: Vec<usize> = package_tables
338        .iter()
339        .enumerate()
340        .take(MAX_ITERATION_COUNT)
341        .filter_map(|(index, package_table)| {
342            package_reference_matches(package_table, reference).then_some(index)
343        })
344        .collect();
345
346    if matches.len() == 1 {
347        matches
348    } else {
349        Vec::new()
350    }
351}
352
353fn package_reference_matches(
354    package_table: &TomlMap<String, TomlValue>,
355    reference: &TomlMap<String, TomlValue>,
356) -> bool {
357    reference.iter().all(|(key, ref_value)| {
358        package_table
359            .get(key)
360            .is_some_and(|pkg_value| toml_values_match(pkg_value, ref_value, 0))
361    })
362}
363
364fn toml_values_match(left: &TomlValue, right: &TomlValue, depth: usize) -> bool {
365    if depth >= MAX_RECURSION_DEPTH {
366        warn!("toml_values_match: recursion depth limit exceeded");
367        return false;
368    }
369    match (left, right) {
370        (TomlValue::String(left), TomlValue::String(right)) => left == right,
371        (TomlValue::Integer(left), TomlValue::Integer(right)) => left == right,
372        (TomlValue::Float(left), TomlValue::Float(right)) => left == right,
373        (TomlValue::Boolean(left), TomlValue::Boolean(right)) => left == right,
374        (TomlValue::Datetime(left), TomlValue::Datetime(right)) => left == right,
375        (TomlValue::Array(left), TomlValue::Array(right)) => {
376            left.len() == right.len()
377                && left
378                    .iter()
379                    .zip(right.iter())
380                    .all(|(left, right)| toml_values_match(left, right, depth + 1))
381        }
382        (TomlValue::Table(left), TomlValue::Table(right)) => {
383            right.iter().all(|(key, right_value)| {
384                left.get(key)
385                    .is_some_and(|left_value| toml_values_match(left_value, right_value, depth + 1))
386            })
387        }
388        _ => false,
389    }
390}
391
392fn build_incoming_counts(package_count: usize, dependency_indices: &[Vec<usize>]) -> Vec<usize> {
393    let mut incoming = vec![0; package_count];
394    for dependency_list in dependency_indices {
395        for &child_index in dependency_list {
396            if let Some(count) = incoming.get_mut(child_index) {
397                *count += 1;
398            }
399        }
400    }
401    incoming
402}
403
404fn collect_reachable_indices(dependency_indices: &[Vec<usize>], roots: &[usize]) -> HashSet<usize> {
405    let mut visited = HashSet::new();
406    let mut queue: VecDeque<usize> = roots.iter().copied().collect();
407
408    while let Some(index) = queue.pop_front() {
409        if visited.len() >= MAX_ITERATION_COUNT {
410            break;
411        }
412        if !visited.insert(index) {
413            continue;
414        }
415
416        for &child_index in dependency_indices.get(index).into_iter().flatten() {
417            queue.push_back(child_index);
418        }
419    }
420
421    visited
422}
423
424fn classify_marker(marker: Option<&str>, default_groups: &HashSet<String>) -> MarkerClassification {
425    let Some(marker) = marker else {
426        return MarkerClassification {
427            is_runtime: true,
428            is_optional: false,
429            scope: None,
430        };
431    };
432
433    let extras = extract_marker_memberships(marker, "extras");
434    if !extras.is_empty() {
435        return MarkerClassification {
436            is_runtime: false,
437            is_optional: true,
438            scope: single_scope(extras),
439        };
440    }
441
442    let groups = extract_marker_memberships(marker, "dependency_groups");
443    let non_default_groups: Vec<String> = groups
444        .into_iter()
445        .filter(|group| !default_groups.contains(group))
446        .collect();
447    if !non_default_groups.is_empty() {
448        return MarkerClassification {
449            is_runtime: false,
450            is_optional: false,
451            scope: single_scope(non_default_groups),
452        };
453    }
454
455    MarkerClassification {
456        is_runtime: true,
457        is_optional: false,
458        scope: None,
459    }
460}
461
462fn extract_marker_memberships(marker: &str, variable_name: &str) -> Vec<String> {
463    let pattern = format!(
464        r#"['\"]([^'\"]+)['\"]\s+in\s+{}\b"#,
465        regex::escape(variable_name)
466    );
467    let Ok(regex) = Regex::new(&pattern) else {
468        return Vec::new();
469    };
470
471    let mut memberships: Vec<String> = regex
472        .captures_iter(marker)
473        .filter_map(|captures| {
474            captures
475                .get(1)
476                .map(|value| truncate_field(value.as_str().trim().to_string()))
477        })
478        .filter(|value| !value.is_empty())
479        .collect();
480    memberships.sort();
481    memberships.dedup();
482    memberships
483}
484
485fn single_scope(values: Vec<String>) -> Option<String> {
486    (values.len() == 1).then(|| values[0].clone())
487}
488
489fn scope_for_index(scope_sets: &HashMap<String, HashSet<usize>>, index: usize) -> Option<String> {
490    let matches: Vec<String> = scope_sets
491        .iter()
492        .filter_map(|(scope, indices)| indices.contains(&index).then_some(scope.clone()))
493        .collect();
494    single_scope(matches)
495}
496
497fn normalized_package_name(package_table: &TomlMap<String, TomlValue>) -> Option<String> {
498    package_table
499        .get(FIELD_NAME)
500        .and_then(TomlValue::as_str)
501        .map(|value| truncate_field(value.trim().to_ascii_lowercase()))
502}
503
504fn package_version(package_table: &TomlMap<String, TomlValue>) -> Option<String> {
505    package_table
506        .get(FIELD_VERSION)
507        .and_then(TomlValue::as_str)
508        .map(|value| truncate_field(value.to_string()))
509}
510
511fn is_package_pinned(package_table: &TomlMap<String, TomlValue>) -> bool {
512    package_table.contains_key(FIELD_VERSION)
513        || package_table
514            .get(FIELD_VCS)
515            .and_then(TomlValue::as_table)
516            .is_some_and(|table| table.contains_key("commit-id"))
517        || has_hashes(package_table.get(FIELD_ARCHIVE))
518        || has_hashes(package_table.get(FIELD_SDIST))
519        || package_table
520            .get(FIELD_WHEELS)
521            .and_then(TomlValue::as_array)
522            .into_iter()
523            .flatten()
524            .filter_map(TomlValue::as_table)
525            .any(|wheel| wheel.contains_key(FIELD_HASHES))
526}
527
528fn has_hashes(value: Option<&TomlValue>) -> bool {
529    value
530        .and_then(TomlValue::as_table)
531        .is_some_and(|table| table.contains_key(FIELD_HASHES))
532}
533
534fn build_lock_extra_data(toml_content: &TomlValue) -> Option<HashMap<String, JsonValue>> {
535    let mut extra_data = HashMap::new();
536
537    for (source_key, target_key) in [
538        (FIELD_LOCK_VERSION, "lock_version"),
539        (FIELD_CREATED_BY, "created_by"),
540        (FIELD_REQUIRES_PYTHON, "requires_python"),
541        (FIELD_ENVIRONMENTS, FIELD_ENVIRONMENTS),
542        (FIELD_EXTRAS, FIELD_EXTRAS),
543        (FIELD_DEPENDENCY_GROUPS, FIELD_DEPENDENCY_GROUPS),
544        (FIELD_DEFAULT_GROUPS, FIELD_DEFAULT_GROUPS),
545    ] {
546        if let Some(value) = toml_content.get(source_key) {
547            extra_data.insert(target_key.to_string(), toml_value_to_json(value));
548        }
549    }
550
551    if let Some(tool) = toml_content.get(FIELD_TOOL) {
552        extra_data.insert(FIELD_TOOL.to_string(), toml_value_to_json(tool));
553    }
554
555    (!extra_data.is_empty()).then_some(extra_data)
556}
557
558fn build_package_extra_data(
559    package_table: &TomlMap<String, TomlValue>,
560) -> Option<HashMap<String, JsonValue>> {
561    let mut extra_data = HashMap::new();
562
563    for key in [
564        FIELD_MARKER,
565        FIELD_REQUIRES_PYTHON,
566        FIELD_INDEX,
567        FIELD_VCS,
568        FIELD_DIRECTORY,
569        FIELD_ARCHIVE,
570        FIELD_SDIST,
571        FIELD_WHEELS,
572        FIELD_TOOL,
573        FIELD_ATTESTATION_IDENTITIES,
574    ] {
575        if let Some(value) = package_table.get(key) {
576            extra_data.insert(key.to_string(), toml_value_to_json(value));
577        }
578    }
579
580    (!extra_data.is_empty()).then_some(extra_data)
581}
582
583fn extract_artifact_metadata(
584    package_table: &TomlMap<String, TomlValue>,
585) -> (
586    Option<String>,
587    Option<String>,
588    Option<String>,
589    Option<String>,
590) {
591    if let Some(archive_table) = package_table
592        .get(FIELD_ARCHIVE)
593        .and_then(TomlValue::as_table)
594    {
595        return (
596            archive_table
597                .get("url")
598                .and_then(TomlValue::as_str)
599                .map(|value| truncate_field(value.to_string()))
600                .or_else(|| {
601                    archive_table
602                        .get("path")
603                        .and_then(TomlValue::as_str)
604                        .map(|value| truncate_field(value.to_string()))
605                }),
606            extract_hash_by_name(archive_table, "sha256"),
607            extract_hash_by_name(archive_table, "sha512"),
608            extract_hash_by_name(archive_table, "md5"),
609        );
610    }
611
612    if let Some(sdist_table) = package_table.get(FIELD_SDIST).and_then(TomlValue::as_table) {
613        return (
614            sdist_table
615                .get("url")
616                .and_then(TomlValue::as_str)
617                .map(|value| truncate_field(value.to_string()))
618                .or_else(|| {
619                    sdist_table
620                        .get("path")
621                        .and_then(TomlValue::as_str)
622                        .map(|value| truncate_field(value.to_string()))
623                }),
624            extract_hash_by_name(sdist_table, "sha256"),
625            extract_hash_by_name(sdist_table, "sha512"),
626            extract_hash_by_name(sdist_table, "md5"),
627        );
628    }
629
630    let wheel_table = package_table
631        .get(FIELD_WHEELS)
632        .and_then(TomlValue::as_array)
633        .and_then(|wheels| wheels.first())
634        .and_then(TomlValue::as_table);
635
636    (
637        wheel_table
638            .and_then(|table| table.get("url"))
639            .and_then(TomlValue::as_str)
640            .map(|value| truncate_field(value.to_string()))
641            .or_else(|| {
642                wheel_table
643                    .and_then(|table| table.get("path"))
644                    .and_then(TomlValue::as_str)
645                    .map(|value| truncate_field(value.to_string()))
646            }),
647        wheel_table.and_then(|table| extract_hash_by_name(table, "sha256")),
648        wheel_table.and_then(|table| extract_hash_by_name(table, "sha512")),
649        wheel_table.and_then(|table| extract_hash_by_name(table, "md5")),
650    )
651}
652
653fn extract_hash_by_name(table: &TomlMap<String, TomlValue>, name: &str) -> Option<String> {
654    table
655        .get(FIELD_HASHES)
656        .and_then(TomlValue::as_table)
657        .and_then(|hashes| hashes.get(name))
658        .and_then(TomlValue::as_str)
659        .map(|value| truncate_field(value.to_string()))
660}
661
662fn extract_string_set(toml_content: &TomlValue, key: &str) -> HashSet<String> {
663    toml_content
664        .get(key)
665        .and_then(TomlValue::as_array)
666        .into_iter()
667        .flatten()
668        .filter_map(TomlValue::as_str)
669        .map(|value| value.to_string())
670        .collect()
671}
672
673fn build_pypi_urls(
674    name: Option<&str>,
675    version: Option<&str>,
676) -> (
677    Option<String>,
678    Option<String>,
679    Option<String>,
680    Option<String>,
681) {
682    let repository_homepage_url = name.map(|value| format!("https://pypi.org/project/{}", value));
683    let repository_download_url = name.and_then(|value| {
684        version.map(|ver| {
685            format!(
686                "https://pypi.org/packages/source/{}/{}/{}-{}.tar.gz",
687                &value[..1.min(value.len())],
688                value,
689                value,
690                ver
691            )
692        })
693    });
694    let api_data_url = name.map(|value| {
695        if let Some(ver) = version {
696            format!("https://pypi.org/pypi/{}/{}/json", value, ver)
697        } else {
698            format!("https://pypi.org/pypi/{}/json", value)
699        }
700    });
701    let purl = name.and_then(|value| create_pypi_purl(value, version));
702
703    (
704        repository_homepage_url,
705        repository_download_url,
706        api_data_url,
707        purl,
708    )
709}
710
711fn create_pypi_purl(name: &str, version: Option<&str>) -> Option<String> {
712    if let Ok(mut purl) = PackageUrl::new(PylockTomlParser::PACKAGE_TYPE.as_str(), name) {
713        if let Some(version) = version
714            && purl.with_version(version).is_err()
715        {
716            return None;
717        }
718        return Some(truncate_field(purl.to_string()));
719    }
720
721    let mut purl = format!("pkg:pypi/{}", name);
722    if let Some(version) = version
723        && !version.is_empty()
724    {
725        purl.push('@');
726        purl.push_str(version);
727    }
728    Some(truncate_field(purl))
729}
730
731fn toml_value_to_json(value: &TomlValue) -> JsonValue {
732    match value {
733        TomlValue::String(value) => JsonValue::String(value.clone()),
734        TomlValue::Integer(value) => JsonValue::String(value.to_string()),
735        TomlValue::Float(value) => JsonValue::String(value.to_string()),
736        TomlValue::Boolean(value) => JsonValue::Bool(*value),
737        TomlValue::Datetime(value) => JsonValue::String(value.to_string()),
738        TomlValue::Array(values) => {
739            JsonValue::Array(values.iter().map(toml_value_to_json).collect())
740        }
741        TomlValue::Table(values) => JsonValue::Object(
742            values
743                .iter()
744                .map(|(key, value)| (key.clone(), toml_value_to_json(value)))
745                .collect::<JsonMap<String, JsonValue>>(),
746        ),
747    }
748}
749
750fn default_package_data() -> PackageData {
751    PackageData {
752        package_type: Some(PylockTomlParser::PACKAGE_TYPE),
753        primary_language: Some("Python".to_string()),
754        datasource_id: Some(DatasourceId::PypiPylockToml),
755        ..Default::default()
756    }
757}
758
759crate::register_parser!(
760    "pylock.toml lockfile",
761    &["**/pylock.toml", "**/pylock.*.toml"],
762    "pypi",
763    "Python",
764    Some("https://packaging.python.org/en/latest/specifications/pylock-toml/"),
765);