Skip to main content

provenant/parsers/
pylock_toml.rs

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