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