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