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