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