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    // pixi.lock formats 4 and 5 share the `kind`-tagged package layout; format 6 switched
192    // to `conda:`/`pypi:` discriminated entries. Unknown/newer versions evolve from the v6
193    // layout, so fall back to the v6 extractor rather than dropping all dependencies.
194    match lock_version {
195        Some(4) | Some(5) => package.dependencies = extract_v4_lock_dependencies(lock_content),
196        Some(6) => package.dependencies = extract_v6_lock_dependencies(lock_content),
197        Some(other) => {
198            warn!("Unrecognized pixi.lock version {other}; parsing with the v6 layout");
199            package.dependencies = extract_v6_lock_dependencies(lock_content);
200        }
201        None => {
202            warn!("pixi.lock is missing a version field; parsing with the v6 layout");
203            package.dependencies = extract_v6_lock_dependencies(lock_content);
204        }
205    }
206
207    package
208}
209
210fn extract_authors(identity: Option<&TomlMap<String, TomlValue>>) -> Vec<Party> {
211    identity
212        .and_then(|table| table.get(FIELD_AUTHORS))
213        .and_then(TomlValue::as_array)
214        .into_iter()
215        .flatten()
216        .take(MAX_ITERATION_COUNT)
217        .filter_map(TomlValue::as_str)
218        .map(|author| {
219            let (name, email) = split_name_email(author);
220            Party {
221                r#type: None,
222                role: Some("author".to_string()),
223                name: name.map(truncate_field),
224                email: email.map(truncate_field),
225                url: None,
226                organization: None,
227                organization_url: None,
228                timezone: None,
229            }
230        })
231        .collect()
232}
233
234fn extract_manifest_file_references(
235    identity: Option<&TomlMap<String, TomlValue>>,
236) -> Vec<FileReference> {
237    let Some(identity) = identity else {
238        return Vec::new();
239    };
240
241    let mut references = Vec::new();
242
243    if let Some(path) = identity.get(FIELD_LICENSE_FILE).and_then(TomlValue::as_str) {
244        let path = path.trim();
245        if !path.is_empty() {
246            references.push(FileReference {
247                path: truncate_field(path.to_string()),
248                size: None,
249                sha1: None,
250                md5: None,
251                sha256: None,
252                sha512: None,
253                extra_data: None,
254            });
255        }
256    }
257
258    if let Some(path) = identity.get(FIELD_README).and_then(TomlValue::as_str) {
259        let path = path.trim();
260        if !path.is_empty() {
261            let already_present = references.iter().any(|reference| reference.path == path);
262            if !already_present {
263                references.push(FileReference {
264                    path: truncate_field(path.to_string()),
265                    size: None,
266                    sha1: None,
267                    md5: None,
268                    sha256: None,
269                    sha512: None,
270                    extra_data: None,
271                });
272            }
273        }
274    }
275
276    references
277}
278
279fn extract_manifest_dependencies(toml_content: &TomlValue) -> Vec<Dependency> {
280    let mut dependencies = Vec::new();
281
282    if let Some(table) = toml_content
283        .get(FIELD_DEPENDENCIES)
284        .and_then(TomlValue::as_table)
285    {
286        dependencies.extend(extract_conda_dependencies(table, None, false));
287    }
288    if let Some(table) = toml_content
289        .get(FIELD_PYPI_DEPENDENCIES)
290        .and_then(TomlValue::as_table)
291    {
292        dependencies.extend(extract_pypi_dependencies(table, None, false));
293    }
294
295    if let Some(feature_table) = toml_content
296        .get(FIELD_FEATURE)
297        .and_then(TomlValue::as_table)
298    {
299        for (feature_name, value) in feature_table.iter().take(MAX_ITERATION_COUNT) {
300            let Some(feature) = value.as_table() else {
301                continue;
302            };
303            if let Some(table) = feature
304                .get(FIELD_DEPENDENCIES)
305                .and_then(TomlValue::as_table)
306            {
307                dependencies.extend(extract_conda_dependencies(table, Some(feature_name), true));
308            }
309            if let Some(table) = feature
310                .get(FIELD_PYPI_DEPENDENCIES)
311                .and_then(TomlValue::as_table)
312            {
313                dependencies.extend(extract_pypi_dependencies(table, Some(feature_name), true));
314            }
315        }
316    }
317
318    dependencies
319}
320
321fn extract_conda_dependencies(
322    table: &TomlMap<String, TomlValue>,
323    scope: Option<&str>,
324    optional: bool,
325) -> Vec<Dependency> {
326    table
327        .iter()
328        .take(MAX_ITERATION_COUNT)
329        .filter_map(|(name, value)| build_conda_dependency(name, value, scope, optional))
330        .collect()
331}
332
333fn build_conda_dependency(
334    name: &str,
335    value: &TomlValue,
336    scope: Option<&str>,
337    optional: bool,
338) -> Option<Dependency> {
339    let requirement = extract_conda_requirement(value).map(truncate_field);
340    let exact_requirement = match value {
341        TomlValue::String(value) => Some(truncate_field(value.to_string())),
342        TomlValue::Table(table) => table
343            .get(FIELD_VERSION)
344            .and_then(toml_value_to_string)
345            .map(truncate_field),
346        _ => None,
347    };
348    let pinned = exact_requirement
349        .as_deref()
350        .is_some_and(is_exact_constraint);
351    let exact_version = exact_requirement
352        .as_deref()
353        .filter(|_| pinned)
354        .map(|value| value.trim_start_matches('='));
355    let dep_table = match value {
356        TomlValue::Table(table) => Some(table),
357        _ => None,
358    };
359    let table_field = |key: &str| {
360        dep_table
361            .and_then(|table| table.get(key))
362            .and_then(toml_value_to_string)
363            .map(truncate_field)
364    };
365    let channel = table_field("channel");
366    let build = table_field("build");
367    let purl = build_conda_purl(
368        "conda",
369        channel.as_deref(),
370        name,
371        exact_version,
372        build.as_deref(),
373    )
374    .map(truncate_field);
375
376    let mut extra_data = HashMap::new();
377    if let TomlValue::Table(dep_table) = value {
378        for key in ["channel", "build", "path", "url", "git"] {
379            if let Some(val) = dep_table
380                .get(key)
381                .and_then(toml_value_to_string)
382                .map(truncate_field)
383            {
384                extra_data.insert(key.to_string(), JsonValue::String(val));
385            }
386        }
387    }
388
389    Some(Dependency {
390        purl,
391        extracted_requirement: requirement.clone(),
392        scope: scope.map(|s| truncate_field(s.to_string())),
393        is_runtime: Some(true),
394        is_optional: Some(optional),
395        is_pinned: Some(pinned),
396        is_direct: Some(true),
397        resolved_package: None,
398        extra_data: (!extra_data.is_empty()).then_some(extra_data),
399    })
400}
401
402fn extract_pypi_dependencies(
403    table: &TomlMap<String, TomlValue>,
404    scope: Option<&str>,
405    optional: bool,
406) -> Vec<Dependency> {
407    table
408        .iter()
409        .take(MAX_ITERATION_COUNT)
410        .filter_map(|(name, value)| build_pypi_dependency(name, value, scope, optional))
411        .collect()
412}
413
414fn build_pypi_dependency(
415    name: &str,
416    value: &TomlValue,
417    scope: Option<&str>,
418    optional: bool,
419) -> Option<Dependency> {
420    let normalized_name = normalize_pypi_name(name);
421    let requirement = extract_pypi_requirement(value).map(truncate_field);
422    let exact_requirement = match value {
423        TomlValue::String(value) => Some(truncate_field(value.to_string())),
424        TomlValue::Table(table) => table
425            .get(FIELD_VERSION)
426            .and_then(toml_value_to_string)
427            .map(truncate_field),
428        _ => None,
429    };
430    let pinned = exact_requirement
431        .as_deref()
432        .is_some_and(is_exact_constraint);
433    let exact_version = exact_requirement
434        .as_deref()
435        .filter(|_| pinned)
436        .map(|value| value.trim_start_matches('='));
437    let purl = build_pypi_purl(&normalized_name, exact_version).map(truncate_field);
438
439    let mut extra_data = HashMap::new();
440    if let TomlValue::Table(dep_table) = value {
441        for key in [
442            "index",
443            "path",
444            "git",
445            "url",
446            "branch",
447            "tag",
448            "rev",
449            "subdirectory",
450        ] {
451            if let Some(val) = dep_table
452                .get(key)
453                .and_then(toml_value_to_string)
454                .map(truncate_field)
455            {
456                extra_data.insert(key.replace('-', "_"), JsonValue::String(val));
457            }
458        }
459        if let Some(editable) = dep_table.get("editable").and_then(TomlValue::as_bool) {
460            extra_data.insert("editable".to_string(), JsonValue::Bool(editable));
461        }
462        if let Some(extras) = dep_table.get("extras").and_then(toml_to_json) {
463            extra_data.insert("extras".to_string(), extras);
464        }
465    }
466
467    Some(Dependency {
468        purl,
469        extracted_requirement: requirement.clone(),
470        scope: scope.map(|s| truncate_field(s.to_string())),
471        is_runtime: Some(true),
472        is_optional: Some(optional),
473        is_pinned: Some(pinned),
474        is_direct: Some(true),
475        resolved_package: None,
476        extra_data: (!extra_data.is_empty()).then_some(extra_data),
477    })
478}
479
480fn build_manifest_extra_data(
481    toml_content: &TomlValue,
482    identity: Option<&TomlMap<String, TomlValue>>,
483) -> Option<HashMap<String, JsonValue>> {
484    let mut extra_data = HashMap::new();
485
486    for (field, key) in [
487        (FIELD_CHANNELS, "channels"),
488        (FIELD_PLATFORMS, "platforms"),
489        (FIELD_REQUIRES_PIXI, "requires_pixi"),
490        (FIELD_EXCLUDE_NEWER, "exclude_newer"),
491        (FIELD_LICENSE_FILE, "license_file"),
492        (FIELD_README, "readme"),
493        (FIELD_DOCUMENTATION, "documentation"),
494    ] {
495        if let Some(value) = identity
496            .and_then(|table| table.get(field))
497            .and_then(toml_to_json)
498        {
499            extra_data.insert(key.to_string(), value);
500        }
501    }
502    if let Some(value) = toml_content.get(FIELD_ENVIRONMENTS).and_then(toml_to_json) {
503        extra_data.insert("environments".to_string(), value);
504    }
505    if let Some(value) = toml_content.get(FIELD_TASKS).and_then(toml_to_json) {
506        extra_data.insert("tasks".to_string(), value);
507    }
508    if let Some(value) = toml_content.get(FIELD_PYPI_OPTIONS).and_then(toml_to_json) {
509        extra_data.insert("pypi_options".to_string(), value);
510    }
511    if let Some(feature_names) = toml_content
512        .get(FIELD_FEATURE)
513        .and_then(TomlValue::as_table)
514        .map(|table| table.keys().cloned().collect::<Vec<_>>())
515        .filter(|names| !names.is_empty())
516    {
517        extra_data.insert(
518            "features".to_string(),
519            JsonValue::Array(feature_names.into_iter().map(JsonValue::String).collect()),
520        );
521    }
522
523    (!extra_data.is_empty()).then_some(extra_data)
524}
525
526fn extract_v6_lock_dependencies(lock_content: &JsonValue) -> Vec<Dependency> {
527    let environment_refs = collect_v6_package_refs(lock_content);
528    let Some(packages) = lock_content.get("packages").and_then(JsonValue::as_array) else {
529        return Vec::new();
530    };
531
532    packages
533        .iter()
534        .take(MAX_ITERATION_COUNT)
535        .filter_map(JsonValue::as_object)
536        .filter_map(|table| build_v6_lock_dependency(table, &environment_refs))
537        .collect()
538}
539
540fn collect_v6_package_refs(lock_content: &JsonValue) -> HashMap<String, Vec<JsonValue>> {
541    let mut refs = HashMap::new();
542    let Some(environments) = lock_content
543        .get(FIELD_ENVIRONMENTS)
544        .and_then(JsonValue::as_object)
545    else {
546        return refs;
547    };
548
549    for (env_name, env_value) in environments.iter().take(MAX_ITERATION_COUNT) {
550        let Some(env_table) = env_value.as_object() else {
551            continue;
552        };
553        let channels = env_table.get(FIELD_CHANNELS).cloned();
554        let indexes = env_table.get("indexes").cloned();
555        let Some(package_platforms) = env_table.get("packages").and_then(JsonValue::as_object)
556        else {
557            continue;
558        };
559        for (platform, values) in package_platforms.iter().take(MAX_ITERATION_COUNT) {
560            let Some(entries) = values.as_array() else {
561                continue;
562            };
563            for entry in entries.iter().take(MAX_ITERATION_COUNT) {
564                let Some(table) = entry.as_object() else {
565                    continue;
566                };
567                for (kind, locator_value) in table {
568                    if let Some(locator) = json_value_to_string(locator_value).map(truncate_field) {
569                        let mut data = JsonMap::new();
570                        data.insert(
571                            "environment".to_string(),
572                            JsonValue::String(env_name.clone()),
573                        );
574                        data.insert("platform".to_string(), JsonValue::String(platform.clone()));
575                        data.insert("kind".to_string(), JsonValue::String(kind.clone()));
576                        if let Some(channels) = channels.clone() {
577                            data.insert("channels".to_string(), channels);
578                        }
579                        if let Some(indexes) = indexes.clone() {
580                            data.insert("indexes".to_string(), indexes);
581                        }
582                        refs.entry(locator)
583                            .or_default()
584                            .push(JsonValue::Object(data));
585                    }
586                }
587            }
588        }
589    }
590
591    refs
592}
593
594fn build_v6_lock_dependency(
595    table: &JsonMap<String, JsonValue>,
596    refs: &HashMap<String, Vec<JsonValue>>,
597) -> Option<Dependency> {
598    if let Some(locator) = table
599        .get("pypi")
600        .and_then(json_value_to_string)
601        .map(truncate_field)
602    {
603        let name = table
604            .get(FIELD_NAME)
605            .and_then(JsonValue::as_str)
606            .map(normalize_pypi_name)?;
607        let version = table
608            .get(FIELD_VERSION)
609            .and_then(json_value_to_string)
610            .map(truncate_field)?;
611        let mut extra = HashMap::new();
612        extra.insert("source".to_string(), JsonValue::String(locator.clone()));
613        if let Some(val) = table.get("requires_dist").cloned() {
614            extra.insert("requires_dist".to_string(), val);
615        }
616        if let Some(val) = table.get("requires_python").cloned() {
617            extra.insert("requires_python".to_string(), val);
618        }
619        for key in ["sha256", "md5"] {
620            if let Some(val) = table.get(key).cloned() {
621                extra.insert(key.to_string(), val);
622            }
623        }
624        if let Some(values) = refs.get(&locator)
625            && !values.is_empty()
626        {
627            extra.insert(
628                "lock_references".to_string(),
629                JsonValue::Array(values.clone()),
630            );
631        }
632        return Some(Dependency {
633            purl: build_pypi_purl(&name, Some(&version)).map(truncate_field),
634            extracted_requirement: Some(version.clone()),
635            scope: None,
636            is_runtime: None,
637            is_optional: None,
638            is_pinned: Some(true),
639            is_direct: None,
640            resolved_package: None,
641            extra_data: Some(extra),
642        });
643    }
644
645    if let Some(locator) = table
646        .get("conda")
647        .and_then(json_value_to_string)
648        .map(truncate_field)
649    {
650        let name = conda_name_from_locator(&locator)?;
651        let version = table
652            .get(FIELD_VERSION)
653            .and_then(json_value_to_string)
654            .map(truncate_field);
655        let mut extra = HashMap::new();
656        extra.insert("source".to_string(), JsonValue::String(locator.clone()));
657        for key in [
658            "sha256",
659            "md5",
660            "license",
661            "license_family",
662            "depends",
663            "constrains",
664            "purls",
665        ] {
666            if let Some(val) = table.get(key).cloned() {
667                extra.insert(key.to_string(), val);
668            }
669        }
670        if let Some(values) = refs.get(&locator)
671            && !values.is_empty()
672        {
673            extra.insert(
674                "lock_references".to_string(),
675                JsonValue::Array(values.clone()),
676            );
677        }
678        return Some(Dependency {
679            purl: build_conda_purl("conda", None, &name, version.as_deref(), None)
680                .map(truncate_field),
681            extracted_requirement: version,
682            scope: None,
683            is_runtime: None,
684            is_optional: None,
685            is_pinned: Some(true),
686            is_direct: None,
687            resolved_package: None,
688            extra_data: Some(extra),
689        });
690    }
691
692    None
693}
694
695fn extract_v4_lock_dependencies(lock_content: &JsonValue) -> Vec<Dependency> {
696    let Some(packages) = lock_content.get("packages").and_then(JsonValue::as_array) else {
697        return Vec::new();
698    };
699
700    packages
701        .iter()
702        .take(MAX_ITERATION_COUNT)
703        .filter_map(JsonValue::as_object)
704        .filter_map(build_v4_lock_dependency)
705        .collect()
706}
707
708fn build_v4_lock_dependency(table: &JsonMap<String, JsonValue>) -> Option<Dependency> {
709    let kind = table.get("kind").and_then(JsonValue::as_str)?;
710    let name = table
711        .get(FIELD_NAME)
712        .and_then(json_value_to_string)
713        .map(truncate_field)?;
714    let version = table
715        .get(FIELD_VERSION)
716        .and_then(json_value_to_string)
717        .map(truncate_field);
718    let mut extra = HashMap::new();
719    for key in [
720        "url",
721        "path",
722        "sha256",
723        "md5",
724        "editable",
725        "build",
726        "subdir",
727        "license",
728        "license_family",
729        "depends",
730        "requires_dist",
731    ] {
732        if let Some(val) = table.get(key).cloned() {
733            extra.insert(key.replace('-', "_"), val);
734        }
735    }
736
737    Some(Dependency {
738        purl: match kind {
739            "pypi" => {
740                build_pypi_purl(&normalize_pypi_name(&name), version.as_deref()).map(truncate_field)
741            }
742            "conda" => {
743                let build = table
744                    .get("build")
745                    .and_then(json_value_to_string)
746                    .map(truncate_field);
747                build_conda_purl("conda", None, &name, version.as_deref(), build.as_deref())
748                    .map(truncate_field)
749            }
750            _ => None,
751        },
752        extracted_requirement: version,
753        scope: None,
754        is_runtime: None,
755        is_optional: None,
756        is_pinned: Some(true),
757        is_direct: None,
758        resolved_package: None,
759        extra_data: Some(extra),
760    })
761}
762
763fn extract_conda_requirement(value: &TomlValue) -> Option<String> {
764    match value {
765        TomlValue::String(value) => Some(value.to_string()),
766        TomlValue::Table(table) => table
767            .get(FIELD_VERSION)
768            .and_then(toml_value_to_string)
769            .or_else(|| table.get("build").and_then(toml_value_to_string)),
770        _ => None,
771    }
772}
773
774fn extract_pypi_requirement(value: &TomlValue) -> Option<String> {
775    match value {
776        TomlValue::String(value) => Some(value.to_string()),
777        TomlValue::Table(table) => table
778            .get(FIELD_VERSION)
779            .and_then(toml_value_to_string)
780            .or_else(|| table.get("path").and_then(toml_value_to_string))
781            .or_else(|| table.get("git").and_then(toml_value_to_string))
782            .or_else(|| table.get("url").and_then(toml_value_to_string)),
783        _ => None,
784    }
785}
786
787fn toml_value_to_string(value: &TomlValue) -> Option<String> {
788    match value {
789        TomlValue::String(value) => Some(value.clone()),
790        TomlValue::Integer(value) => Some(value.to_string()),
791        TomlValue::Float(value) => Some(value.to_string()),
792        TomlValue::Boolean(value) => Some(value.to_string()),
793        _ => None,
794    }
795}
796
797fn toml_to_json(value: &TomlValue) -> Option<JsonValue> {
798    serde_json::to_value(value).ok()
799}
800
801fn json_value_to_string(value: &JsonValue) -> Option<String> {
802    match value {
803        JsonValue::String(value) => Some(value.clone()),
804        JsonValue::Number(value) => Some(value.to_string()),
805        JsonValue::Bool(value) => Some(value.to_string()),
806        _ => None,
807    }
808}
809
810fn normalize_pypi_name(name: &str) -> String {
811    truncate_field(name.trim().replace('_', "-").to_ascii_lowercase())
812}
813
814fn build_pypi_purl(name: &str, version: Option<&str>) -> Option<String> {
815    let mut purl = PackageUrl::new("pypi", name).ok()?;
816    if let Some(version) = version {
817        purl.with_version(version).ok()?;
818    }
819    Some(truncate_field(purl.to_string()))
820}
821
822fn build_pixi_purl(name: &str, version: Option<&str>) -> Option<String> {
823    let mut purl = PackageUrl::new(PackageType::Pixi.as_str(), name).ok()?;
824    if let Some(version) = version {
825        purl.with_version(version).ok()?;
826    }
827    Some(truncate_field(purl.to_string()))
828}
829
830fn is_exact_constraint(value: &str) -> bool {
831    let trimmed = value.trim();
832    let normalized = trimmed.trim_start_matches('=');
833    !normalized.is_empty()
834        && !normalized.contains('*')
835        && !normalized.contains('^')
836        && !normalized.contains('~')
837        && !normalized.contains('>')
838        && !normalized.contains('<')
839        && !normalized.contains('=')
840        && !normalized.contains('|')
841        && !normalized.contains(',')
842        && !normalized.contains(' ')
843}
844
845fn conda_name_from_locator(locator: &str) -> Option<String> {
846    let file_name = locator.rsplit('/').next()?;
847    let stem = file_name
848        .strip_suffix(".tar.bz2")
849        .or_else(|| file_name.strip_suffix(".conda"))
850        .unwrap_or(file_name);
851    let mut parts = stem.rsplitn(3, '-');
852    let _ = parts.next()?;
853    let _ = parts.next()?;
854    Some(truncate_field(parts.next()?.to_string()))
855}
856
857fn default_package_data(datasource_id: Option<DatasourceId>) -> PackageData {
858    PackageData {
859        package_type: Some(PackageType::Pixi),
860        datasource_id,
861        ..Default::default()
862    }
863}