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