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