1use std::collections::HashMap;
5use std::path::Path;
6
7use crate::parser_warn as warn;
8use packageurl::PackageUrl;
9use serde_json::{Map as JsonMap, Value as JsonValue};
10use toml::Value as TomlValue;
11use toml::map::Map as TomlMap;
12
13use crate::models::{DatasourceId, Dependency, FileReference, PackageData, PackageType, Party};
14use crate::parsers::conda::build_purl as build_conda_purl;
15use crate::parsers::python::read_toml_file;
16use crate::parsers::utils::{CappedIterExt, read_file_to_string, split_name_email, truncate_field};
17
18use super::PackageParser;
19use super::metadata::ParserMetadata;
20
21const FIELD_WORKSPACE: &str = "workspace";
22const FIELD_PROJECT: &str = "project";
23const FIELD_NAME: &str = "name";
24const FIELD_VERSION: &str = "version";
25const FIELD_AUTHORS: &str = "authors";
26const FIELD_DESCRIPTION: &str = "description";
27const FIELD_LICENSE: &str = "license";
28const FIELD_LICENSE_FILE: &str = "license-file";
29const FIELD_README: &str = "readme";
30const FIELD_HOMEPAGE: &str = "homepage";
31const FIELD_REPOSITORY: &str = "repository";
32const FIELD_DOCUMENTATION: &str = "documentation";
33const FIELD_CHANNELS: &str = "channels";
34const FIELD_PLATFORMS: &str = "platforms";
35const FIELD_REQUIRES_PIXI: &str = "requires-pixi";
36const FIELD_EXCLUDE_NEWER: &str = "exclude-newer";
37const FIELD_DEPENDENCIES: &str = "dependencies";
38const FIELD_PYPI_DEPENDENCIES: &str = "pypi-dependencies";
39const FIELD_FEATURE: &str = "feature";
40const FIELD_ENVIRONMENTS: &str = "environments";
41const FIELD_TASKS: &str = "tasks";
42const FIELD_PYPI_OPTIONS: &str = "pypi-options";
43
44pub struct PixiTomlParser;
45
46impl PackageParser for PixiTomlParser {
47 const PACKAGE_TYPE: PackageType = PackageType::Pixi;
48
49 fn metadata() -> Vec<ParserMetadata> {
50 vec![ParserMetadata {
51 description: "Pixi workspace manifest and lockfile",
52 file_patterns: &["**/pixi.toml", "**/pixi.lock"],
53 package_type: "pixi",
54 primary_language: "TOML/YAML",
55 documentation_url: Some("https://pixi.sh/latest/reference/pixi_manifest/"),
56 }]
57 }
58
59 fn is_match(path: &Path) -> bool {
60 path.file_name().is_some_and(|name| name == "pixi.toml")
61 }
62
63 fn extract_packages(path: &Path) -> Vec<PackageData> {
64 let toml_content = match read_toml_file(path) {
65 Ok(content) => content,
66 Err(error) => {
67 warn!("Failed to read pixi.toml at {:?}: {}", path, error);
68 return vec![default_package_data(Some(DatasourceId::PixiToml))];
69 }
70 };
71
72 vec![parse_pixi_toml(&toml_content)]
73 }
74}
75
76pub struct PixiLockParser;
77
78impl PackageParser for PixiLockParser {
79 const PACKAGE_TYPE: PackageType = PackageType::Pixi;
80
81 fn is_match(path: &Path) -> bool {
82 path.file_name().is_some_and(|name| name == "pixi.lock")
83 }
84
85 fn extract_packages(path: &Path) -> Vec<PackageData> {
86 let content = match read_file_to_string(path, None) {
87 Ok(content) => content,
88 Err(error) => {
89 warn!("Failed to read pixi.lock at {:?}: {}", path, error);
90 return vec![default_package_data(Some(DatasourceId::PixiLock))];
91 }
92 };
93
94 let (lock_content, primary_language) = match parse_pixi_lock_document(&content) {
95 Ok(parsed) => parsed,
96 Err(error) => {
97 warn!("Failed to read pixi.lock at {:?}: {}", path, error);
98 return vec![default_package_data(Some(DatasourceId::PixiLock))];
99 }
100 };
101
102 vec![parse_pixi_lock(&lock_content, primary_language)]
103 }
104}
105
106fn parse_pixi_toml(toml_content: &TomlValue) -> PackageData {
107 let identity = toml_content
108 .get(FIELD_WORKSPACE)
109 .and_then(TomlValue::as_table)
110 .or_else(|| {
111 toml_content
112 .get(FIELD_PROJECT)
113 .and_then(TomlValue::as_table)
114 });
115
116 let name = identity
117 .and_then(|table| table.get(FIELD_NAME))
118 .and_then(TomlValue::as_str)
119 .map(|v| truncate_field(v.to_string()));
120 let version = identity
121 .and_then(|table| table.get(FIELD_VERSION))
122 .and_then(toml_value_to_string)
123 .map(truncate_field);
124
125 let mut package = default_package_data(Some(DatasourceId::PixiToml));
126 package.name = name.clone();
127 package.version = version.clone();
128 package.primary_language = Some("TOML".to_string());
129 package.description = identity
130 .and_then(|table| table.get(FIELD_DESCRIPTION))
131 .and_then(TomlValue::as_str)
132 .map(|value| truncate_field(value.trim().to_string()));
133 package.homepage_url = identity
134 .and_then(|table| table.get(FIELD_HOMEPAGE))
135 .and_then(TomlValue::as_str)
136 .map(|v| truncate_field(v.to_string()));
137 package.vcs_url = identity
138 .and_then(|table| table.get(FIELD_REPOSITORY))
139 .and_then(TomlValue::as_str)
140 .map(|v| truncate_field(v.to_string()));
141 package.parties = extract_authors(identity);
142 package.extracted_license_statement = identity
143 .and_then(|table| table.get(FIELD_LICENSE))
144 .and_then(TomlValue::as_str)
145 .map(|v| truncate_field(v.to_string()));
146 package.file_references = extract_manifest_file_references(identity);
147 package.purl = name
148 .as_deref()
149 .and_then(|value| build_pixi_purl(value, version.as_deref()))
150 .map(truncate_field);
151 package.dependencies = extract_manifest_dependencies(toml_content);
152 package.extra_data = build_manifest_extra_data(toml_content, identity);
153 package
154}
155
156fn parse_pixi_lock_document(content: &str) -> Result<(JsonValue, &'static str), String> {
157 match toml::from_str::<TomlValue>(content) {
158 Ok(toml_content) => serde_json::to_value(toml_content)
159 .map(|value| (value, "TOML"))
160 .map_err(|error| format!("Failed to convert TOML lockfile: {error}")),
161 Err(toml_error) => yaml_serde::from_str::<JsonValue>(content)
162 .map(|value| (value, "YAML"))
163 .map_err(|yaml_error| {
164 format!(
165 "Failed to parse Pixi lockfile as TOML ({toml_error}) or YAML ({yaml_error})"
166 )
167 }),
168 }
169}
170
171fn parse_pixi_lock(lock_content: &JsonValue, primary_language: &str) -> PackageData {
172 let mut package = default_package_data(Some(DatasourceId::PixiLock));
173 package.primary_language = Some(primary_language.to_string());
174
175 let lock_version = lock_content.get(FIELD_VERSION).and_then(|value| {
176 value
177 .as_i64()
178 .or_else(|| value.as_str()?.parse::<i64>().ok())
179 });
180 let mut extra_data = HashMap::new();
181 if let Some(lock_version) = lock_version {
182 extra_data.insert("lock_version".to_string(), JsonValue::from(lock_version));
183 }
184 if let Some(env_json) = lock_content.get(FIELD_ENVIRONMENTS).cloned() {
185 extra_data.insert("lock_environments".to_string(), env_json);
186 }
187 package.extra_data = (!extra_data.is_empty()).then_some(extra_data);
188
189 match lock_version {
193 Some(4) | Some(5) => package.dependencies = extract_v4_lock_dependencies(lock_content),
194 Some(6) => package.dependencies = extract_v6_lock_dependencies(lock_content),
195 Some(other) => {
196 warn!("Unrecognized pixi.lock version {other}; parsing with the v6 layout");
197 package.dependencies = extract_v6_lock_dependencies(lock_content);
198 }
199 None => {
200 warn!("pixi.lock is missing a version field; parsing with the v6 layout");
201 package.dependencies = extract_v6_lock_dependencies(lock_content);
202 }
203 }
204
205 package
206}
207
208fn extract_authors(identity: Option<&TomlMap<String, TomlValue>>) -> Vec<Party> {
209 identity
210 .and_then(|table| table.get(FIELD_AUTHORS))
211 .and_then(TomlValue::as_array)
212 .into_iter()
213 .flatten()
214 .capped("pixi authors")
215 .filter_map(TomlValue::as_str)
216 .map(|author| {
217 let (name, email) = split_name_email(author);
218 Party {
219 r#type: None,
220 role: Some("author".to_string()),
221 name: name.map(truncate_field),
222 email: email.map(truncate_field),
223 url: None,
224 organization: None,
225 organization_url: None,
226 timezone: None,
227 }
228 })
229 .collect()
230}
231
232fn extract_manifest_file_references(
233 identity: Option<&TomlMap<String, TomlValue>>,
234) -> Vec<FileReference> {
235 let Some(identity) = identity else {
236 return Vec::new();
237 };
238
239 let mut references = Vec::new();
240
241 if let Some(path) = identity.get(FIELD_LICENSE_FILE).and_then(TomlValue::as_str) {
242 let path = path.trim();
243 if !path.is_empty() {
244 references.push(FileReference {
245 path: truncate_field(path.to_string()),
246 size: None,
247 sha1: None,
248 md5: None,
249 sha256: None,
250 sha512: None,
251 extra_data: None,
252 });
253 }
254 }
255
256 if let Some(path) = identity.get(FIELD_README).and_then(TomlValue::as_str) {
257 let path = path.trim();
258 if !path.is_empty() {
259 let already_present = references.iter().any(|reference| reference.path == path);
260 if !already_present {
261 references.push(FileReference {
262 path: truncate_field(path.to_string()),
263 size: None,
264 sha1: None,
265 md5: None,
266 sha256: None,
267 sha512: None,
268 extra_data: None,
269 });
270 }
271 }
272 }
273
274 references
275}
276
277fn extract_manifest_dependencies(toml_content: &TomlValue) -> Vec<Dependency> {
278 let mut dependencies = Vec::new();
279
280 if let Some(table) = toml_content
281 .get(FIELD_DEPENDENCIES)
282 .and_then(TomlValue::as_table)
283 {
284 dependencies.extend(extract_conda_dependencies(table, None, false));
285 }
286 if let Some(table) = toml_content
287 .get(FIELD_PYPI_DEPENDENCIES)
288 .and_then(TomlValue::as_table)
289 {
290 dependencies.extend(extract_pypi_dependencies(table, None, false));
291 }
292
293 if let Some(feature_table) = toml_content
294 .get(FIELD_FEATURE)
295 .and_then(TomlValue::as_table)
296 {
297 for (feature_name, value) in feature_table.iter().capped("pixi features") {
298 let Some(feature) = value.as_table() else {
299 continue;
300 };
301 if let Some(table) = feature
302 .get(FIELD_DEPENDENCIES)
303 .and_then(TomlValue::as_table)
304 {
305 dependencies.extend(extract_conda_dependencies(table, Some(feature_name), true));
306 }
307 if let Some(table) = feature
308 .get(FIELD_PYPI_DEPENDENCIES)
309 .and_then(TomlValue::as_table)
310 {
311 dependencies.extend(extract_pypi_dependencies(table, Some(feature_name), true));
312 }
313 }
314 }
315
316 dependencies
317}
318
319fn extract_conda_dependencies(
320 table: &TomlMap<String, TomlValue>,
321 scope: Option<&str>,
322 optional: bool,
323) -> Vec<Dependency> {
324 table
325 .iter()
326 .capped("pixi conda dependencies")
327 .filter_map(|(name, value)| build_conda_dependency(name, value, scope, optional))
328 .collect()
329}
330
331fn build_conda_dependency(
332 name: &str,
333 value: &TomlValue,
334 scope: Option<&str>,
335 optional: bool,
336) -> Option<Dependency> {
337 let requirement = extract_conda_requirement(value).map(truncate_field);
338 let exact_requirement = match value {
339 TomlValue::String(value) => Some(truncate_field(value.to_string())),
340 TomlValue::Table(table) => table
341 .get(FIELD_VERSION)
342 .and_then(toml_value_to_string)
343 .map(truncate_field),
344 _ => None,
345 };
346 let pinned = exact_requirement
347 .as_deref()
348 .is_some_and(is_exact_constraint);
349 let exact_version = exact_requirement
350 .as_deref()
351 .filter(|_| pinned)
352 .map(|value| value.trim_start_matches('='));
353 let dep_table = match value {
354 TomlValue::Table(table) => Some(table),
355 _ => None,
356 };
357 let table_field = |key: &str| {
358 dep_table
359 .and_then(|table| table.get(key))
360 .and_then(toml_value_to_string)
361 .map(truncate_field)
362 };
363 let channel = table_field("channel");
364 let build = table_field("build");
365 let purl = build_conda_purl(
366 "conda",
367 channel.as_deref(),
368 name,
369 exact_version,
370 build.as_deref(),
371 )
372 .map(truncate_field);
373
374 let mut extra_data = HashMap::new();
375 if let TomlValue::Table(dep_table) = value {
376 for key in ["channel", "build", "path", "url", "git"] {
377 if let Some(val) = dep_table
378 .get(key)
379 .and_then(toml_value_to_string)
380 .map(truncate_field)
381 {
382 extra_data.insert(key.to_string(), JsonValue::String(val));
383 }
384 }
385 }
386
387 Some(Dependency {
388 purl,
389 extracted_requirement: requirement.clone(),
390 scope: scope.map(|s| truncate_field(s.to_string())),
391 is_runtime: Some(true),
392 is_optional: Some(optional),
393 is_pinned: Some(pinned),
394 is_direct: Some(true),
395 resolved_package: None,
396 extra_data: (!extra_data.is_empty()).then_some(extra_data),
397 })
398}
399
400fn extract_pypi_dependencies(
401 table: &TomlMap<String, TomlValue>,
402 scope: Option<&str>,
403 optional: bool,
404) -> Vec<Dependency> {
405 table
406 .iter()
407 .capped("pixi pypi dependencies")
408 .filter_map(|(name, value)| build_pypi_dependency(name, value, scope, optional))
409 .collect()
410}
411
412fn build_pypi_dependency(
413 name: &str,
414 value: &TomlValue,
415 scope: Option<&str>,
416 optional: bool,
417) -> Option<Dependency> {
418 let normalized_name = normalize_pypi_name(name);
419 let requirement = extract_pypi_requirement(value).map(truncate_field);
420 let exact_requirement = match value {
421 TomlValue::String(value) => Some(truncate_field(value.to_string())),
422 TomlValue::Table(table) => table
423 .get(FIELD_VERSION)
424 .and_then(toml_value_to_string)
425 .map(truncate_field),
426 _ => None,
427 };
428 let pinned = exact_requirement
429 .as_deref()
430 .is_some_and(is_exact_constraint);
431 let exact_version = exact_requirement
432 .as_deref()
433 .filter(|_| pinned)
434 .map(|value| value.trim_start_matches('='));
435 let purl = build_pypi_purl(&normalized_name, exact_version).map(truncate_field);
436
437 let mut extra_data = HashMap::new();
438 if let TomlValue::Table(dep_table) = value {
439 for key in [
440 "index",
441 "path",
442 "git",
443 "url",
444 "branch",
445 "tag",
446 "rev",
447 "subdirectory",
448 ] {
449 if let Some(val) = dep_table
450 .get(key)
451 .and_then(toml_value_to_string)
452 .map(truncate_field)
453 {
454 extra_data.insert(key.replace('-', "_"), JsonValue::String(val));
455 }
456 }
457 if let Some(editable) = dep_table.get("editable").and_then(TomlValue::as_bool) {
458 extra_data.insert("editable".to_string(), JsonValue::Bool(editable));
459 }
460 if let Some(extras) = dep_table.get("extras").and_then(toml_to_json) {
461 extra_data.insert("extras".to_string(), extras);
462 }
463 }
464
465 Some(Dependency {
466 purl,
467 extracted_requirement: requirement.clone(),
468 scope: scope.map(|s| truncate_field(s.to_string())),
469 is_runtime: Some(true),
470 is_optional: Some(optional),
471 is_pinned: Some(pinned),
472 is_direct: Some(true),
473 resolved_package: None,
474 extra_data: (!extra_data.is_empty()).then_some(extra_data),
475 })
476}
477
478fn build_manifest_extra_data(
479 toml_content: &TomlValue,
480 identity: Option<&TomlMap<String, TomlValue>>,
481) -> Option<HashMap<String, JsonValue>> {
482 let mut extra_data = HashMap::new();
483
484 for (field, key) in [
485 (FIELD_CHANNELS, "channels"),
486 (FIELD_PLATFORMS, "platforms"),
487 (FIELD_REQUIRES_PIXI, "requires_pixi"),
488 (FIELD_EXCLUDE_NEWER, "exclude_newer"),
489 (FIELD_LICENSE_FILE, "license_file"),
490 (FIELD_README, "readme"),
491 (FIELD_DOCUMENTATION, "documentation"),
492 ] {
493 if let Some(value) = identity
494 .and_then(|table| table.get(field))
495 .and_then(toml_to_json)
496 {
497 extra_data.insert(key.to_string(), value);
498 }
499 }
500 if let Some(value) = toml_content.get(FIELD_ENVIRONMENTS).and_then(toml_to_json) {
501 extra_data.insert("environments".to_string(), value);
502 }
503 if let Some(value) = toml_content.get(FIELD_TASKS).and_then(toml_to_json) {
504 extra_data.insert("tasks".to_string(), value);
505 }
506 if let Some(value) = toml_content.get(FIELD_PYPI_OPTIONS).and_then(toml_to_json) {
507 extra_data.insert("pypi_options".to_string(), value);
508 }
509 if let Some(feature_names) = toml_content
510 .get(FIELD_FEATURE)
511 .and_then(TomlValue::as_table)
512 .map(|table| table.keys().cloned().collect::<Vec<_>>())
513 .filter(|names| !names.is_empty())
514 {
515 extra_data.insert(
516 "features".to_string(),
517 JsonValue::Array(feature_names.into_iter().map(JsonValue::String).collect()),
518 );
519 }
520
521 (!extra_data.is_empty()).then_some(extra_data)
522}
523
524fn extract_v6_lock_dependencies(lock_content: &JsonValue) -> Vec<Dependency> {
525 let environment_refs = collect_v6_package_refs(lock_content);
526 let Some(packages) = lock_content.get("packages").and_then(JsonValue::as_array) else {
527 return Vec::new();
528 };
529
530 packages
531 .iter()
532 .capped("pixi v6 lock packages")
533 .filter_map(JsonValue::as_object)
534 .filter_map(|table| build_v6_lock_dependency(table, &environment_refs))
535 .collect()
536}
537
538fn collect_v6_package_refs(lock_content: &JsonValue) -> HashMap<String, Vec<JsonValue>> {
539 let mut refs = HashMap::new();
540 let Some(environments) = lock_content
541 .get(FIELD_ENVIRONMENTS)
542 .and_then(JsonValue::as_object)
543 else {
544 return refs;
545 };
546
547 for (env_name, env_value) in environments.iter().capped("pixi v6 lock environments") {
548 let Some(env_table) = env_value.as_object() else {
549 continue;
550 };
551 let channels = env_table.get(FIELD_CHANNELS).cloned();
552 let indexes = env_table.get("indexes").cloned();
553 let Some(package_platforms) = env_table.get("packages").and_then(JsonValue::as_object)
554 else {
555 continue;
556 };
557 for (platform, values) in package_platforms.iter().capped("pixi v6 lock platforms") {
558 let Some(entries) = values.as_array() else {
559 continue;
560 };
561 for entry in entries.iter().capped("pixi v6 lock platform entries") {
562 let Some(table) = entry.as_object() else {
563 continue;
564 };
565 for (kind, locator_value) in table {
566 if let Some(locator) = json_value_to_string(locator_value).map(truncate_field) {
567 let mut data = JsonMap::new();
568 data.insert(
569 "environment".to_string(),
570 JsonValue::String(env_name.clone()),
571 );
572 data.insert("platform".to_string(), JsonValue::String(platform.clone()));
573 data.insert("kind".to_string(), JsonValue::String(kind.clone()));
574 if let Some(channels) = channels.clone() {
575 data.insert("channels".to_string(), channels);
576 }
577 if let Some(indexes) = indexes.clone() {
578 data.insert("indexes".to_string(), indexes);
579 }
580 refs.entry(locator)
581 .or_default()
582 .push(JsonValue::Object(data));
583 }
584 }
585 }
586 }
587 }
588
589 refs
590}
591
592fn build_v6_lock_dependency(
593 table: &JsonMap<String, JsonValue>,
594 refs: &HashMap<String, Vec<JsonValue>>,
595) -> Option<Dependency> {
596 if let Some(locator) = table
597 .get("pypi")
598 .and_then(json_value_to_string)
599 .map(truncate_field)
600 {
601 let name = table
602 .get(FIELD_NAME)
603 .and_then(JsonValue::as_str)
604 .map(normalize_pypi_name)?;
605 let version = table
606 .get(FIELD_VERSION)
607 .and_then(json_value_to_string)
608 .map(truncate_field)?;
609 let mut extra = HashMap::new();
610 extra.insert("source".to_string(), JsonValue::String(locator.clone()));
611 if let Some(val) = table.get("requires_dist").cloned() {
612 extra.insert("requires_dist".to_string(), val);
613 }
614 if let Some(val) = table.get("requires_python").cloned() {
615 extra.insert("requires_python".to_string(), val);
616 }
617 for key in ["sha256", "md5"] {
618 if let Some(val) = table.get(key).cloned() {
619 extra.insert(key.to_string(), val);
620 }
621 }
622 if let Some(values) = refs.get(&locator)
623 && !values.is_empty()
624 {
625 extra.insert(
626 "lock_references".to_string(),
627 JsonValue::Array(values.clone()),
628 );
629 }
630 return Some(Dependency {
631 purl: build_pypi_purl(&name, Some(&version)).map(truncate_field),
632 extracted_requirement: Some(version.clone()),
633 scope: None,
634 is_runtime: None,
635 is_optional: None,
636 is_pinned: Some(true),
637 is_direct: None,
638 resolved_package: None,
639 extra_data: Some(extra),
640 });
641 }
642
643 if let Some(locator) = table
644 .get("conda")
645 .and_then(json_value_to_string)
646 .map(truncate_field)
647 {
648 let name = conda_name_from_locator(&locator)?;
649 let version = table
650 .get(FIELD_VERSION)
651 .and_then(json_value_to_string)
652 .map(truncate_field);
653 let mut extra = HashMap::new();
654 extra.insert("source".to_string(), JsonValue::String(locator.clone()));
655 for key in [
656 "sha256",
657 "md5",
658 "license",
659 "license_family",
660 "depends",
661 "constrains",
662 "purls",
663 ] {
664 if let Some(val) = table.get(key).cloned() {
665 extra.insert(key.to_string(), val);
666 }
667 }
668 if let Some(values) = refs.get(&locator)
669 && !values.is_empty()
670 {
671 extra.insert(
672 "lock_references".to_string(),
673 JsonValue::Array(values.clone()),
674 );
675 }
676 return Some(Dependency {
677 purl: build_conda_purl("conda", None, &name, version.as_deref(), None)
678 .map(truncate_field),
679 extracted_requirement: version,
680 scope: None,
681 is_runtime: None,
682 is_optional: None,
683 is_pinned: Some(true),
684 is_direct: None,
685 resolved_package: None,
686 extra_data: Some(extra),
687 });
688 }
689
690 None
691}
692
693fn extract_v4_lock_dependencies(lock_content: &JsonValue) -> Vec<Dependency> {
694 let Some(packages) = lock_content.get("packages").and_then(JsonValue::as_array) else {
695 return Vec::new();
696 };
697
698 packages
699 .iter()
700 .capped("pixi v4 lock packages")
701 .filter_map(JsonValue::as_object)
702 .filter_map(build_v4_lock_dependency)
703 .collect()
704}
705
706fn build_v4_lock_dependency(table: &JsonMap<String, JsonValue>) -> Option<Dependency> {
707 let kind = table.get("kind").and_then(JsonValue::as_str)?;
708 let name = table
709 .get(FIELD_NAME)
710 .and_then(json_value_to_string)
711 .map(truncate_field)?;
712 let version = table
713 .get(FIELD_VERSION)
714 .and_then(json_value_to_string)
715 .map(truncate_field);
716 let mut extra = HashMap::new();
717 for key in [
718 "url",
719 "path",
720 "sha256",
721 "md5",
722 "editable",
723 "build",
724 "subdir",
725 "license",
726 "license_family",
727 "depends",
728 "requires_dist",
729 ] {
730 if let Some(val) = table.get(key).cloned() {
731 extra.insert(key.replace('-', "_"), val);
732 }
733 }
734
735 Some(Dependency {
736 purl: match kind {
737 "pypi" => {
738 build_pypi_purl(&normalize_pypi_name(&name), version.as_deref()).map(truncate_field)
739 }
740 "conda" => {
741 let build = table
742 .get("build")
743 .and_then(json_value_to_string)
744 .map(truncate_field);
745 build_conda_purl("conda", None, &name, version.as_deref(), build.as_deref())
746 .map(truncate_field)
747 }
748 _ => None,
749 },
750 extracted_requirement: version,
751 scope: None,
752 is_runtime: None,
753 is_optional: None,
754 is_pinned: Some(true),
755 is_direct: None,
756 resolved_package: None,
757 extra_data: Some(extra),
758 })
759}
760
761fn extract_conda_requirement(value: &TomlValue) -> Option<String> {
762 match value {
763 TomlValue::String(value) => Some(value.to_string()),
764 TomlValue::Table(table) => table
765 .get(FIELD_VERSION)
766 .and_then(toml_value_to_string)
767 .or_else(|| table.get("build").and_then(toml_value_to_string)),
768 _ => None,
769 }
770}
771
772fn extract_pypi_requirement(value: &TomlValue) -> Option<String> {
773 match value {
774 TomlValue::String(value) => Some(value.to_string()),
775 TomlValue::Table(table) => table
776 .get(FIELD_VERSION)
777 .and_then(toml_value_to_string)
778 .or_else(|| table.get("path").and_then(toml_value_to_string))
779 .or_else(|| table.get("git").and_then(toml_value_to_string))
780 .or_else(|| table.get("url").and_then(toml_value_to_string)),
781 _ => None,
782 }
783}
784
785fn toml_value_to_string(value: &TomlValue) -> Option<String> {
786 match value {
787 TomlValue::String(value) => Some(value.clone()),
788 TomlValue::Integer(value) => Some(value.to_string()),
789 TomlValue::Float(value) => Some(value.to_string()),
790 TomlValue::Boolean(value) => Some(value.to_string()),
791 _ => None,
792 }
793}
794
795fn toml_to_json(value: &TomlValue) -> Option<JsonValue> {
796 serde_json::to_value(value).ok()
797}
798
799fn json_value_to_string(value: &JsonValue) -> Option<String> {
800 match value {
801 JsonValue::String(value) => Some(value.clone()),
802 JsonValue::Number(value) => Some(value.to_string()),
803 JsonValue::Bool(value) => Some(value.to_string()),
804 _ => None,
805 }
806}
807
808fn normalize_pypi_name(name: &str) -> String {
809 truncate_field(name.trim().replace('_', "-").to_ascii_lowercase())
810}
811
812fn build_pypi_purl(name: &str, version: Option<&str>) -> Option<String> {
813 let mut purl = PackageUrl::new("pypi", name).ok()?;
814 if let Some(version) = version {
815 purl.with_version(version).ok()?;
816 }
817 Some(truncate_field(purl.to_string()))
818}
819
820fn build_pixi_purl(name: &str, version: Option<&str>) -> Option<String> {
821 let mut purl = PackageUrl::new(PackageType::Pixi.as_str(), name).ok()?;
822 if let Some(version) = version {
823 purl.with_version(version).ok()?;
824 }
825 Some(truncate_field(purl.to_string()))
826}
827
828fn is_exact_constraint(value: &str) -> bool {
829 let trimmed = value.trim();
830 let normalized = trimmed.trim_start_matches('=');
831 !normalized.is_empty()
832 && !normalized.contains('*')
833 && !normalized.contains('^')
834 && !normalized.contains('~')
835 && !normalized.contains('>')
836 && !normalized.contains('<')
837 && !normalized.contains('=')
838 && !normalized.contains('|')
839 && !normalized.contains(',')
840 && !normalized.contains(' ')
841}
842
843fn conda_name_from_locator(locator: &str) -> Option<String> {
844 let file_name = locator.rsplit('/').next()?;
845 let stem = file_name
846 .strip_suffix(".tar.bz2")
847 .or_else(|| file_name.strip_suffix(".conda"))
848 .unwrap_or(file_name);
849 let mut parts = stem.rsplitn(3, '-');
850 let _ = parts.next()?;
851 let _ = parts.next()?;
852 Some(truncate_field(parts.next()?.to_string()))
853}
854
855fn default_package_data(datasource_id: Option<DatasourceId>) -> PackageData {
856 PackageData {
857 package_type: Some(PackageType::Pixi),
858 datasource_id,
859 ..Default::default()
860 }
861}