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 {
192 Some(6) => package.dependencies = extract_v6_lock_dependencies(lock_content),
193 Some(4) => package.dependencies = extract_v4_lock_dependencies(lock_content),
194 Some(_) | None => {}
195 }
196
197 package
198}
199
200fn extract_authors(identity: Option<&TomlMap<String, TomlValue>>) -> Vec<Party> {
201 identity
202 .and_then(|table| table.get(FIELD_AUTHORS))
203 .and_then(TomlValue::as_array)
204 .into_iter()
205 .flatten()
206 .take(MAX_ITERATION_COUNT)
207 .filter_map(TomlValue::as_str)
208 .map(|author| {
209 let (name, email) = split_name_email(author);
210 Party {
211 r#type: None,
212 role: Some("author".to_string()),
213 name: name.map(truncate_field),
214 email: email.map(truncate_field),
215 url: None,
216 organization: None,
217 organization_url: None,
218 timezone: None,
219 }
220 })
221 .collect()
222}
223
224fn extract_manifest_file_references(
225 identity: Option<&TomlMap<String, TomlValue>>,
226) -> Vec<FileReference> {
227 let Some(identity) = identity else {
228 return Vec::new();
229 };
230
231 let mut references = Vec::new();
232
233 if let Some(path) = identity.get(FIELD_LICENSE_FILE).and_then(TomlValue::as_str) {
234 let path = path.trim();
235 if !path.is_empty() {
236 references.push(FileReference {
237 path: truncate_field(path.to_string()),
238 size: None,
239 sha1: None,
240 md5: None,
241 sha256: None,
242 sha512: None,
243 extra_data: None,
244 });
245 }
246 }
247
248 if let Some(path) = identity.get(FIELD_README).and_then(TomlValue::as_str) {
249 let path = path.trim();
250 if !path.is_empty() {
251 let already_present = references.iter().any(|reference| reference.path == path);
252 if !already_present {
253 references.push(FileReference {
254 path: truncate_field(path.to_string()),
255 size: None,
256 sha1: None,
257 md5: None,
258 sha256: None,
259 sha512: None,
260 extra_data: None,
261 });
262 }
263 }
264 }
265
266 references
267}
268
269fn extract_manifest_dependencies(toml_content: &TomlValue) -> Vec<Dependency> {
270 let mut dependencies = Vec::new();
271
272 if let Some(table) = toml_content
273 .get(FIELD_DEPENDENCIES)
274 .and_then(TomlValue::as_table)
275 {
276 dependencies.extend(extract_conda_dependencies(table, None, false));
277 }
278 if let Some(table) = toml_content
279 .get(FIELD_PYPI_DEPENDENCIES)
280 .and_then(TomlValue::as_table)
281 {
282 dependencies.extend(extract_pypi_dependencies(table, None, false));
283 }
284
285 if let Some(feature_table) = toml_content
286 .get(FIELD_FEATURE)
287 .and_then(TomlValue::as_table)
288 {
289 for (feature_name, value) in feature_table.iter().take(MAX_ITERATION_COUNT) {
290 let Some(feature) = value.as_table() else {
291 continue;
292 };
293 if let Some(table) = feature
294 .get(FIELD_DEPENDENCIES)
295 .and_then(TomlValue::as_table)
296 {
297 dependencies.extend(extract_conda_dependencies(table, Some(feature_name), true));
298 }
299 if let Some(table) = feature
300 .get(FIELD_PYPI_DEPENDENCIES)
301 .and_then(TomlValue::as_table)
302 {
303 dependencies.extend(extract_pypi_dependencies(table, Some(feature_name), true));
304 }
305 }
306 }
307
308 dependencies
309}
310
311fn extract_conda_dependencies(
312 table: &TomlMap<String, TomlValue>,
313 scope: Option<&str>,
314 optional: bool,
315) -> Vec<Dependency> {
316 table
317 .iter()
318 .take(MAX_ITERATION_COUNT)
319 .filter_map(|(name, value)| build_conda_dependency(name, value, scope, optional))
320 .collect()
321}
322
323fn build_conda_dependency(
324 name: &str,
325 value: &TomlValue,
326 scope: Option<&str>,
327 optional: bool,
328) -> Option<Dependency> {
329 let requirement = extract_conda_requirement(value).map(truncate_field);
330 let exact_requirement = match value {
331 TomlValue::String(value) => Some(truncate_field(value.to_string())),
332 TomlValue::Table(table) => table
333 .get(FIELD_VERSION)
334 .and_then(toml_value_to_string)
335 .map(truncate_field),
336 _ => None,
337 };
338 let pinned = exact_requirement
339 .as_deref()
340 .is_some_and(is_exact_constraint);
341 let exact_version = exact_requirement
342 .as_deref()
343 .filter(|_| pinned)
344 .map(|value| value.trim_start_matches('='));
345 let purl =
346 build_conda_purl("conda", None, name, exact_version, None, None, None).map(truncate_field);
347
348 let mut extra_data = HashMap::new();
349 if let TomlValue::Table(dep_table) = value {
350 for key in ["channel", "build", "path", "url", "git"] {
351 if let Some(val) = dep_table
352 .get(key)
353 .and_then(toml_value_to_string)
354 .map(truncate_field)
355 {
356 extra_data.insert(key.to_string(), JsonValue::String(val));
357 }
358 }
359 }
360
361 Some(Dependency {
362 purl,
363 extracted_requirement: requirement.clone(),
364 scope: scope.map(|s| truncate_field(s.to_string())),
365 is_runtime: Some(true),
366 is_optional: Some(optional),
367 is_pinned: Some(pinned),
368 is_direct: Some(true),
369 resolved_package: None,
370 extra_data: (!extra_data.is_empty()).then_some(extra_data),
371 })
372}
373
374fn extract_pypi_dependencies(
375 table: &TomlMap<String, TomlValue>,
376 scope: Option<&str>,
377 optional: bool,
378) -> Vec<Dependency> {
379 table
380 .iter()
381 .take(MAX_ITERATION_COUNT)
382 .filter_map(|(name, value)| build_pypi_dependency(name, value, scope, optional))
383 .collect()
384}
385
386fn build_pypi_dependency(
387 name: &str,
388 value: &TomlValue,
389 scope: Option<&str>,
390 optional: bool,
391) -> Option<Dependency> {
392 let normalized_name = normalize_pypi_name(name);
393 let requirement = extract_pypi_requirement(value).map(truncate_field);
394 let exact_requirement = match value {
395 TomlValue::String(value) => Some(truncate_field(value.to_string())),
396 TomlValue::Table(table) => table
397 .get(FIELD_VERSION)
398 .and_then(toml_value_to_string)
399 .map(truncate_field),
400 _ => None,
401 };
402 let pinned = exact_requirement
403 .as_deref()
404 .is_some_and(is_exact_constraint);
405 let exact_version = exact_requirement
406 .as_deref()
407 .filter(|_| pinned)
408 .map(|value| value.trim_start_matches('='));
409 let purl = build_pypi_purl(&normalized_name, exact_version).map(truncate_field);
410
411 let mut extra_data = HashMap::new();
412 if let TomlValue::Table(dep_table) = value {
413 for key in [
414 "index",
415 "path",
416 "git",
417 "url",
418 "branch",
419 "tag",
420 "rev",
421 "subdirectory",
422 ] {
423 if let Some(val) = dep_table
424 .get(key)
425 .and_then(toml_value_to_string)
426 .map(truncate_field)
427 {
428 extra_data.insert(key.replace('-', "_"), JsonValue::String(val));
429 }
430 }
431 if let Some(editable) = dep_table.get("editable").and_then(TomlValue::as_bool) {
432 extra_data.insert("editable".to_string(), JsonValue::Bool(editable));
433 }
434 if let Some(extras) = dep_table.get("extras").and_then(toml_to_json) {
435 extra_data.insert("extras".to_string(), extras);
436 }
437 }
438
439 Some(Dependency {
440 purl,
441 extracted_requirement: requirement.clone(),
442 scope: scope.map(|s| truncate_field(s.to_string())),
443 is_runtime: Some(true),
444 is_optional: Some(optional),
445 is_pinned: Some(pinned),
446 is_direct: Some(true),
447 resolved_package: None,
448 extra_data: (!extra_data.is_empty()).then_some(extra_data),
449 })
450}
451
452fn build_manifest_extra_data(
453 toml_content: &TomlValue,
454 identity: Option<&TomlMap<String, TomlValue>>,
455) -> Option<HashMap<String, JsonValue>> {
456 let mut extra_data = HashMap::new();
457
458 for (field, key) in [
459 (FIELD_CHANNELS, "channels"),
460 (FIELD_PLATFORMS, "platforms"),
461 (FIELD_REQUIRES_PIXI, "requires_pixi"),
462 (FIELD_EXCLUDE_NEWER, "exclude_newer"),
463 (FIELD_LICENSE_FILE, "license_file"),
464 (FIELD_README, "readme"),
465 (FIELD_DOCUMENTATION, "documentation"),
466 ] {
467 if let Some(value) = identity
468 .and_then(|table| table.get(field))
469 .and_then(toml_to_json)
470 {
471 extra_data.insert(key.to_string(), value);
472 }
473 }
474 if let Some(value) = toml_content.get(FIELD_ENVIRONMENTS).and_then(toml_to_json) {
475 extra_data.insert("environments".to_string(), value);
476 }
477 if let Some(value) = toml_content.get(FIELD_TASKS).and_then(toml_to_json) {
478 extra_data.insert("tasks".to_string(), value);
479 }
480 if let Some(value) = toml_content.get(FIELD_PYPI_OPTIONS).and_then(toml_to_json) {
481 extra_data.insert("pypi_options".to_string(), value);
482 }
483 if let Some(feature_names) = toml_content
484 .get(FIELD_FEATURE)
485 .and_then(TomlValue::as_table)
486 .map(|table| table.keys().cloned().collect::<Vec<_>>())
487 .filter(|names| !names.is_empty())
488 {
489 extra_data.insert(
490 "features".to_string(),
491 JsonValue::Array(feature_names.into_iter().map(JsonValue::String).collect()),
492 );
493 }
494
495 (!extra_data.is_empty()).then_some(extra_data)
496}
497
498fn extract_v6_lock_dependencies(lock_content: &JsonValue) -> Vec<Dependency> {
499 let environment_refs = collect_v6_package_refs(lock_content);
500 let Some(packages) = lock_content.get("packages").and_then(JsonValue::as_array) else {
501 return Vec::new();
502 };
503
504 packages
505 .iter()
506 .take(MAX_ITERATION_COUNT)
507 .filter_map(JsonValue::as_object)
508 .filter_map(|table| build_v6_lock_dependency(table, &environment_refs))
509 .collect()
510}
511
512fn collect_v6_package_refs(lock_content: &JsonValue) -> HashMap<String, Vec<JsonValue>> {
513 let mut refs = HashMap::new();
514 let Some(environments) = lock_content
515 .get(FIELD_ENVIRONMENTS)
516 .and_then(JsonValue::as_object)
517 else {
518 return refs;
519 };
520
521 for (env_name, env_value) in environments.iter().take(MAX_ITERATION_COUNT) {
522 let Some(env_table) = env_value.as_object() else {
523 continue;
524 };
525 let channels = env_table.get(FIELD_CHANNELS).cloned();
526 let indexes = env_table.get("indexes").cloned();
527 let Some(package_platforms) = env_table.get("packages").and_then(JsonValue::as_object)
528 else {
529 continue;
530 };
531 for (platform, values) in package_platforms.iter().take(MAX_ITERATION_COUNT) {
532 let Some(entries) = values.as_array() else {
533 continue;
534 };
535 for entry in entries.iter().take(MAX_ITERATION_COUNT) {
536 let Some(table) = entry.as_object() else {
537 continue;
538 };
539 for (kind, locator_value) in table {
540 if let Some(locator) = json_value_to_string(locator_value).map(truncate_field) {
541 let mut data = JsonMap::new();
542 data.insert(
543 "environment".to_string(),
544 JsonValue::String(env_name.clone()),
545 );
546 data.insert("platform".to_string(), JsonValue::String(platform.clone()));
547 data.insert("kind".to_string(), JsonValue::String(kind.clone()));
548 if let Some(channels) = channels.clone() {
549 data.insert("channels".to_string(), channels);
550 }
551 if let Some(indexes) = indexes.clone() {
552 data.insert("indexes".to_string(), indexes);
553 }
554 refs.entry(locator)
555 .or_default()
556 .push(JsonValue::Object(data));
557 }
558 }
559 }
560 }
561 }
562
563 refs
564}
565
566fn build_v6_lock_dependency(
567 table: &JsonMap<String, JsonValue>,
568 refs: &HashMap<String, Vec<JsonValue>>,
569) -> Option<Dependency> {
570 if let Some(locator) = table
571 .get("pypi")
572 .and_then(json_value_to_string)
573 .map(truncate_field)
574 {
575 let name = table
576 .get(FIELD_NAME)
577 .and_then(JsonValue::as_str)
578 .map(normalize_pypi_name)?;
579 let version = table
580 .get(FIELD_VERSION)
581 .and_then(json_value_to_string)
582 .map(truncate_field)?;
583 let mut extra = HashMap::new();
584 extra.insert("source".to_string(), JsonValue::String(locator.clone()));
585 if let Some(val) = table.get("requires_dist").cloned() {
586 extra.insert("requires_dist".to_string(), val);
587 }
588 if let Some(val) = table.get("requires_python").cloned() {
589 extra.insert("requires_python".to_string(), val);
590 }
591 for key in ["sha256", "md5"] {
592 if let Some(val) = table.get(key).cloned() {
593 extra.insert(key.to_string(), val);
594 }
595 }
596 if let Some(values) = refs.get(&locator)
597 && !values.is_empty()
598 {
599 extra.insert(
600 "lock_references".to_string(),
601 JsonValue::Array(values.clone()),
602 );
603 }
604 return Some(Dependency {
605 purl: build_pypi_purl(&name, Some(&version)).map(truncate_field),
606 extracted_requirement: Some(version.clone()),
607 scope: None,
608 is_runtime: None,
609 is_optional: None,
610 is_pinned: Some(true),
611 is_direct: None,
612 resolved_package: None,
613 extra_data: Some(extra),
614 });
615 }
616
617 if let Some(locator) = table
618 .get("conda")
619 .and_then(json_value_to_string)
620 .map(truncate_field)
621 {
622 let name = conda_name_from_locator(&locator)?;
623 let version = table
624 .get(FIELD_VERSION)
625 .and_then(json_value_to_string)
626 .map(truncate_field);
627 let mut extra = HashMap::new();
628 extra.insert("source".to_string(), JsonValue::String(locator.clone()));
629 for key in [
630 "sha256",
631 "md5",
632 "license",
633 "license_family",
634 "depends",
635 "constrains",
636 "purls",
637 ] {
638 if let Some(val) = table.get(key).cloned() {
639 extra.insert(key.to_string(), val);
640 }
641 }
642 if let Some(values) = refs.get(&locator)
643 && !values.is_empty()
644 {
645 extra.insert(
646 "lock_references".to_string(),
647 JsonValue::Array(values.clone()),
648 );
649 }
650 return Some(Dependency {
651 purl: build_conda_purl("conda", None, &name, version.as_deref(), None, None, None)
652 .map(truncate_field),
653 extracted_requirement: version,
654 scope: None,
655 is_runtime: None,
656 is_optional: None,
657 is_pinned: Some(true),
658 is_direct: None,
659 resolved_package: None,
660 extra_data: Some(extra),
661 });
662 }
663
664 None
665}
666
667fn extract_v4_lock_dependencies(lock_content: &JsonValue) -> Vec<Dependency> {
668 let Some(packages) = lock_content.get("packages").and_then(JsonValue::as_array) else {
669 return Vec::new();
670 };
671
672 packages
673 .iter()
674 .take(MAX_ITERATION_COUNT)
675 .filter_map(JsonValue::as_object)
676 .filter_map(build_v4_lock_dependency)
677 .collect()
678}
679
680fn build_v4_lock_dependency(table: &JsonMap<String, JsonValue>) -> Option<Dependency> {
681 let kind = table.get("kind").and_then(JsonValue::as_str)?;
682 let name = table
683 .get(FIELD_NAME)
684 .and_then(json_value_to_string)
685 .map(truncate_field)?;
686 let version = table
687 .get(FIELD_VERSION)
688 .and_then(json_value_to_string)
689 .map(truncate_field);
690 let mut extra = HashMap::new();
691 for key in [
692 "url",
693 "path",
694 "sha256",
695 "md5",
696 "editable",
697 "build",
698 "subdir",
699 "license",
700 "license_family",
701 "depends",
702 "requires_dist",
703 ] {
704 if let Some(val) = table.get(key).cloned() {
705 extra.insert(key.replace('-', "_"), val);
706 }
707 }
708
709 Some(Dependency {
710 purl: match kind {
711 "pypi" => {
712 build_pypi_purl(&normalize_pypi_name(&name), version.as_deref()).map(truncate_field)
713 }
714 "conda" => build_conda_purl("conda", None, &name, version.as_deref(), None, None, None)
715 .map(truncate_field),
716 _ => None,
717 },
718 extracted_requirement: version,
719 scope: None,
720 is_runtime: None,
721 is_optional: None,
722 is_pinned: Some(true),
723 is_direct: None,
724 resolved_package: None,
725 extra_data: Some(extra),
726 })
727}
728
729fn extract_conda_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("build").and_then(toml_value_to_string)),
736 _ => None,
737 }
738}
739
740fn extract_pypi_requirement(value: &TomlValue) -> Option<String> {
741 match value {
742 TomlValue::String(value) => Some(value.to_string()),
743 TomlValue::Table(table) => table
744 .get(FIELD_VERSION)
745 .and_then(toml_value_to_string)
746 .or_else(|| table.get("path").and_then(toml_value_to_string))
747 .or_else(|| table.get("git").and_then(toml_value_to_string))
748 .or_else(|| table.get("url").and_then(toml_value_to_string)),
749 _ => None,
750 }
751}
752
753fn toml_value_to_string(value: &TomlValue) -> Option<String> {
754 match value {
755 TomlValue::String(value) => Some(value.clone()),
756 TomlValue::Integer(value) => Some(value.to_string()),
757 TomlValue::Float(value) => Some(value.to_string()),
758 TomlValue::Boolean(value) => Some(value.to_string()),
759 _ => None,
760 }
761}
762
763fn toml_to_json(value: &TomlValue) -> Option<JsonValue> {
764 serde_json::to_value(value).ok()
765}
766
767fn json_value_to_string(value: &JsonValue) -> Option<String> {
768 match value {
769 JsonValue::String(value) => Some(value.clone()),
770 JsonValue::Number(value) => Some(value.to_string()),
771 JsonValue::Bool(value) => Some(value.to_string()),
772 _ => None,
773 }
774}
775
776fn normalize_pypi_name(name: &str) -> String {
777 truncate_field(name.trim().replace('_', "-").to_ascii_lowercase())
778}
779
780fn build_pypi_purl(name: &str, version: Option<&str>) -> Option<String> {
781 let mut purl = PackageUrl::new("pypi", name).ok()?;
782 if let Some(version) = version {
783 purl.with_version(version).ok()?;
784 }
785 Some(truncate_field(purl.to_string()))
786}
787
788fn build_pixi_purl(name: &str, version: Option<&str>) -> Option<String> {
789 let mut purl = PackageUrl::new(PackageType::Pixi.as_str(), name).ok()?;
790 if let Some(version) = version {
791 purl.with_version(version).ok()?;
792 }
793 Some(truncate_field(purl.to_string()))
794}
795
796fn is_exact_constraint(value: &str) -> bool {
797 let trimmed = value.trim();
798 let normalized = trimmed.trim_start_matches('=');
799 !normalized.is_empty()
800 && !normalized.contains('*')
801 && !normalized.contains('^')
802 && !normalized.contains('~')
803 && !normalized.contains('>')
804 && !normalized.contains('<')
805 && !normalized.contains('=')
806 && !normalized.contains('|')
807 && !normalized.contains(',')
808 && !normalized.contains(' ')
809}
810
811fn conda_name_from_locator(locator: &str) -> Option<String> {
812 let file_name = locator.rsplit('/').next()?;
813 let stem = file_name
814 .strip_suffix(".tar.bz2")
815 .or_else(|| file_name.strip_suffix(".conda"))
816 .unwrap_or(file_name);
817 let mut parts = stem.rsplitn(3, '-');
818 let _ = parts.next()?;
819 let _ = parts.next()?;
820 Some(truncate_field(parts.next()?.to_string()))
821}
822
823fn default_package_data(datasource_id: Option<DatasourceId>) -> PackageData {
824 PackageData {
825 package_type: Some(PackageType::Pixi),
826 datasource_id,
827 ..Default::default()
828 }
829}