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