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