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