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