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