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