Skip to main content

provenant/parsers/
pixi.rs

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