Skip to main content

provenant/parsers/
pixi.rs

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);