Skip to main content

provenant/parsers/
conda.rs

1// SPDX-FileCopyrightText: nexB Inc. and others
2// SPDX-FileCopyrightText: Provenant contributors
3// SPDX-License-Identifier: Apache-2.0
4// Derived from ScanCode Toolkit (Apache-2.0); modified. See NOTICE.
5
6//! Parser for Conda/Anaconda package manifest files.
7//!
8//! Extracts package metadata and dependencies from Conda ecosystem manifest files
9//! supporting both recipe definitions and environment specifications.
10//!
11//! # Supported Formats
12//! - meta.yaml (Conda recipe metadata with Jinja2 templating support)
13//! - conda.yaml/environment.yml (Conda environment dependency specifications)
14//!
15//! # Key Features
16//! - YAML parsing for environment files
17//! - Dependency extraction from dependencies and build_requirements sections
18//! - Channel specification and platform detection
19//! - Version constraint parsing for Conda version specifiers
20//! - Package URL (purl) generation for conda packages
21//! - Limited meta.yaml support (note: Jinja2 templating not fully resolved)
22//!
23//! # Implementation Notes
24//! - Uses YAML parsing via `yaml_serde`
25//! - meta.yaml: Jinja2 templates not evaluated (use rendered YAML if available)
26//! - environment.yml: Full dependency specification support
27//! - Graceful error handling with `warn!()` logs
28//!
29//! # References
30//! - <https://docs.conda.io/projects/conda-build/en/latest/resources/define-metadata.html>
31//! - <https://docs.conda.io/projects/conda/en/latest/user-guide/tasks/manage-environments.html>
32
33use std::collections::HashMap;
34use std::path::Path;
35
36use packageurl::PackageUrl;
37
38use crate::parser_warn as warn;
39use crate::parsers::utils::{
40    CappedIterExt, capped_iteration_limit, read_file_to_string, truncate_field,
41};
42use regex::Regex;
43use yaml_serde::Value;
44
45use crate::models::{DatasourceId, Dependency, PackageData, PackageType, Sha256Digest};
46
47use super::PackageParser;
48use super::license_normalization::{
49    DeclaredLicenseMatchMetadata, build_declared_license_data_from_pair,
50    normalize_spdx_declared_license,
51};
52
53fn default_package_data(datasource_id: Option<DatasourceId>) -> PackageData {
54    PackageData {
55        package_type: Some(CondaMetaYamlParser::PACKAGE_TYPE),
56        datasource_id,
57        ..Default::default()
58    }
59}
60
61fn is_conda_recipe_yaml_path(path: &Path) -> bool {
62    let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
63        return false;
64    };
65    if name != "recipe.yaml" && name != "recipe.yml" {
66        return false;
67    }
68    path.parent()
69        .and_then(|parent| parent.file_name())
70        .and_then(|name| name.to_str())
71        .is_some_and(|name| name == "recipe")
72}
73
74/// Build a PURL (Package URL) for Conda or PyPI packages.
75///
76/// The conda purl-spec prohibits a namespace: the channel is a `?channel=`
77/// qualifier (not a namespace segment) and the build string is a `?build=`
78/// qualifier. `channel`/`build` are ignored for non-conda types.
79pub(crate) fn build_purl(
80    package_type: &str,
81    channel: Option<&str>,
82    name: &str,
83    version: Option<&str>,
84    build: Option<&str>,
85) -> Option<String> {
86    let mut purl = PackageUrl::new(package_type, name).ok()?;
87
88    if let Some(version) = version.filter(|v| !v.is_empty()) {
89        purl.with_version(version).ok()?;
90    }
91
92    if package_type == "conda" {
93        if let Some(channel) = channel.filter(|c| !c.is_empty()) {
94            purl.add_qualifier("channel", channel).ok()?;
95        }
96        if let Some(build) = build.filter(|b| !b.is_empty()) {
97            purl.add_qualifier("build", build).ok()?;
98        }
99    }
100
101    Some(purl.to_string())
102}
103
104fn build_conda_package_purl(name: Option<&str>, version: Option<&str>) -> Option<String> {
105    let name = name?;
106    build_purl("conda", None, name, version, None)
107}
108
109fn yaml_value_to_string(value: &Value) -> Option<String> {
110    match value {
111        Value::String(s) => Some(truncate_field(s.clone())),
112        Value::Number(n) => Some(truncate_field(n.to_string())),
113        Value::Bool(b) => Some(truncate_field(b.to_string())),
114        _ => None,
115    }
116}
117
118fn extract_jinja_statement(trimmed_line: &str) -> Option<&str> {
119    if !trimmed_line.starts_with("{%") {
120        return None;
121    }
122
123    let end = trimmed_line.find("%}")?;
124    Some(trimmed_line[2..end].trim())
125}
126
127fn extract_conda_requirement_name(req: &str) -> Option<String> {
128    let req = req.trim();
129    if req.is_empty() {
130        return None;
131    }
132
133    let req_without_ns = req.rsplit_once("::").map(|(_, rest)| rest).unwrap_or(req);
134
135    let name = req_without_ns
136        .split_whitespace()
137        .next()
138        .unwrap_or(req_without_ns)
139        .split(['=', '<', '>', '!', '~'])
140        .next()
141        .unwrap_or(req_without_ns)
142        .trim();
143
144    if name.is_empty() {
145        None
146    } else {
147        Some(truncate_field(name.to_string()))
148    }
149}
150
151/// Conda recipe manifest (meta.yaml) parser.
152///
153/// Extracts package metadata and dependencies from Conda recipe files, which
154/// define how to build a Conda package. Handles Jinja2 templating used in
155/// recipe files for variable substitution.
156pub struct CondaMetaYamlParser;
157
158impl PackageParser for CondaMetaYamlParser {
159    const PACKAGE_TYPE: PackageType = PackageType::Conda;
160
161    fn is_match(path: &Path) -> bool {
162        // Match */meta.yaml following Python reference logic
163        path.file_name()
164            .is_some_and(|name| name == "meta.yaml" || name == "meta.yml")
165            || is_conda_recipe_yaml_path(path)
166    }
167
168    fn extract_packages(path: &Path) -> Vec<PackageData> {
169        let contents = match read_file_to_string(path, None) {
170            Ok(c) => c,
171            Err(e) => {
172                warn!("Failed to read {}: {}", path.display(), e);
173                return vec![default_package_data(Some(DatasourceId::CondaMetaYaml))];
174            }
175        };
176
177        if is_conda_recipe_yaml_path(path) {
178            let yaml: Value = match yaml_serde::from_str(&contents) {
179                Ok(y) => y,
180                Err(e) => {
181                    warn!("Failed to parse YAML in {}: {}", path.display(), e);
182                    return vec![default_package_data(Some(DatasourceId::CondaMetaYaml))];
183                }
184            };
185
186            if !looks_like_conda_recipe_yaml(&yaml) {
187                return Vec::new();
188            }
189
190            return vec![parse_conda_recipe_yaml(&yaml)];
191        }
192
193        // Extract Jinja2 variables and apply crude substitution
194        let variables = extract_jinja2_variables(&contents);
195        let processed_yaml = apply_jinja2_substitutions(&contents, &variables);
196
197        // Parse YAML after Jinja2 processing
198        let yaml: Value = match yaml_serde::from_str(&processed_yaml) {
199            Ok(y) => y,
200            Err(e) => {
201                warn!("Failed to parse YAML in {}: {}", path.display(), e);
202                return vec![default_package_data(Some(DatasourceId::CondaMetaYaml))];
203            }
204        };
205
206        let package_element = yaml.get("package").and_then(|v| v.as_mapping());
207        let name = package_element
208            .and_then(|p| p.get("name"))
209            .and_then(yaml_value_to_string);
210
211        let version = package_element
212            .and_then(|p| p.get("version"))
213            .and_then(yaml_value_to_string);
214
215        let source = yaml.get("source").and_then(|v| v.as_mapping());
216        let download_url = source
217            .and_then(|s| s.get("url"))
218            .and_then(|v| v.as_str())
219            .map(|s| truncate_field(s.to_string()));
220
221        let sha256 = source
222            .and_then(|s| s.get("sha256"))
223            .and_then(|v| v.as_str())
224            .and_then(|s| Sha256Digest::from_hex(s).ok());
225
226        let about = yaml.get("about").and_then(|v| v.as_mapping());
227        let homepage_url = about
228            .and_then(|a| a.get("home"))
229            .and_then(|v| v.as_str())
230            .map(|s| truncate_field(s.to_string()));
231
232        let extracted_license_statement = about
233            .and_then(|a| a.get("license"))
234            .and_then(|v| v.as_str())
235            .map(|s| truncate_field(s.to_string()));
236        let (declared_license_expression, declared_license_expression_spdx, license_detections) =
237            normalize_conda_declared_license(extracted_license_statement.as_deref());
238
239        let description = about
240            .and_then(|a| a.get("summary"))
241            .and_then(|v| v.as_str())
242            .map(|s| truncate_field(s.to_string()));
243
244        let vcs_url = about
245            .and_then(|a| a.get("dev_url"))
246            .and_then(|v| v.as_str())
247            .map(|s| truncate_field(s.to_string()));
248        let license_file = about
249            .and_then(|a| a.get("license_file"))
250            .and_then(|v| v.as_str())
251            .map(str::trim)
252            .filter(|value| !value.is_empty())
253            .map(|s| truncate_field(s.to_string()));
254
255        // Extract dependencies from requirements sections
256        let mut dependencies = Vec::new();
257        let mut extra_data: HashMap<String, serde_json::Value> = HashMap::new();
258
259        if let Some(requirements) = yaml.get("requirements").and_then(|v| v.as_mapping()) {
260            for (scope_key, reqs_value) in requirements {
261                let scope = scope_key.as_str().unwrap_or("unknown");
262                if let Some(reqs) = reqs_value.as_sequence() {
263                    let limit = capped_iteration_limit(reqs.len(), "conda meta.yaml requirements");
264                    for req in reqs.iter().take(limit) {
265                        if let Some(req_str) = req.as_str()
266                            && let Some(dep) = parse_conda_requirement(req_str, scope)
267                        {
268                            // Filter out pip/python from dependencies, add to extra_data
269                            if extract_conda_requirement_name(req_str)
270                                .is_some_and(|n| n == "pip" || n == "python")
271                            {
272                                if let Some(arr) = extra_data
273                                    .entry(scope.to_string())
274                                    .or_insert_with(|| serde_json::Value::Array(vec![]))
275                                    .as_array_mut()
276                                {
277                                    arr.push(serde_json::Value::String(truncate_field(
278                                        req_str.to_string(),
279                                    )))
280                                }
281                            } else {
282                                dependencies.push(dep);
283                            }
284                        }
285                    }
286                }
287            }
288        }
289
290        let mut pkg = default_package_data(Some(DatasourceId::CondaMetaYaml));
291        pkg.package_type = Some(Self::PACKAGE_TYPE);
292        pkg.datasource_id = Some(DatasourceId::CondaMetaYaml);
293        pkg.name = name;
294        pkg.version = version;
295        pkg.purl = build_conda_package_purl(pkg.name.as_deref(), pkg.version.as_deref());
296        pkg.download_url = download_url;
297        pkg.homepage_url = homepage_url;
298        pkg.declared_license_expression = declared_license_expression.map(truncate_field);
299        pkg.declared_license_expression_spdx = declared_license_expression_spdx.map(truncate_field);
300        pkg.license_detections = license_detections;
301        pkg.extracted_license_statement = extracted_license_statement.map(truncate_field);
302        pkg.description = description;
303        pkg.vcs_url = vcs_url;
304        pkg.sha256 = sha256;
305        pkg.dependencies = dependencies;
306        if let Some(license_file) = license_file {
307            extra_data.insert(
308                "license_file".to_string(),
309                serde_json::Value::String(license_file),
310            );
311        }
312        if !extra_data.is_empty() {
313            pkg.extra_data = Some(extra_data);
314        }
315        vec![pkg]
316    }
317
318    fn metadata() -> Vec<super::metadata::ParserMetadata> {
319        vec![super::metadata::ParserMetadata {
320            description: "Conda package manifest and environment file",
321            file_patterns: &[
322                "**/meta.yaml",
323                "**/meta.yml",
324                "**/recipe/recipe.yaml",
325                "**/recipe/recipe.yml",
326                "**/environment.yml",
327                "**/environment.yaml",
328                "**/env.yaml",
329                "**/env.yml",
330                "**/conda.yaml",
331                "**/conda.yml",
332                "**/*conda*.yaml",
333                "**/*conda*.yml",
334                "**/*env*.yaml",
335                "**/*env*.yml",
336                "**/*environment*.yaml",
337                "**/*environment*.yml",
338            ],
339            package_type: "conda",
340            primary_language: "Python",
341            documentation_url: Some("https://docs.conda.io/"),
342        }]
343    }
344}
345
346fn looks_like_conda_recipe_yaml(yaml: &Value) -> bool {
347    // rattler-build treats schema_version as optional, defaulting to 1, so many real
348    // recipe.yaml files omit it. Accept an absent schema_version (default 1) and reject
349    // only an explicit version this parser does not understand.
350    let schema_version_ok = match yaml.get("schema_version").and_then(|value| value.as_u64()) {
351        Some(version) => version == 1,
352        None => true,
353    };
354
355    schema_version_ok
356        && (yaml
357            .get("package")
358            .and_then(|value| value.as_mapping())
359            .is_some()
360            || yaml
361                .get("recipe")
362                .and_then(|value| value.as_mapping())
363                .is_some())
364}
365
366fn parse_conda_recipe_yaml(yaml: &Value) -> PackageData {
367    let context = extract_recipe_yaml_context(yaml);
368    let package = yaml
369        .get("package")
370        .or_else(|| yaml.get("recipe"))
371        .and_then(|value| value.as_mapping());
372    let source = yaml.get("source").and_then(|value| value.as_mapping());
373    let about = yaml.get("about").and_then(|value| value.as_mapping());
374
375    let name = package
376        .and_then(|pkg| pkg.get("name"))
377        .and_then(|value| recipe_yaml_value_to_string(value, &context));
378    let version = package
379        .and_then(|pkg| pkg.get("version"))
380        .and_then(|value| recipe_yaml_value_to_string(value, &context));
381
382    let download_url = source
383        .and_then(|src| src.get("url"))
384        .and_then(|value| recipe_yaml_value_to_string(value, &context));
385    let sha256 = source
386        .and_then(|src| src.get("sha256"))
387        .and_then(|value| recipe_yaml_value_to_string(value, &context))
388        .and_then(|value| Sha256Digest::from_hex(&value).ok());
389
390    let extracted_license_statement = about
391        .and_then(|section| section.get("license"))
392        .and_then(|value| recipe_yaml_value_to_string(value, &context));
393    let (declared_license_expression, declared_license_expression_spdx, license_detections) =
394        normalize_conda_declared_license(extracted_license_statement.as_deref());
395
396    let description = about
397        .and_then(|section| section.get("summary"))
398        .and_then(|value| recipe_yaml_value_to_string(value, &context));
399    let homepage_url = about
400        .and_then(|section| section.get("homepage").or_else(|| section.get("home")))
401        .and_then(|value| recipe_yaml_value_to_string(value, &context));
402    let vcs_url = about
403        .and_then(|section| {
404            section
405                .get("repository")
406                .or_else(|| section.get("dev_url"))
407                .or_else(|| section.get("repository_url"))
408        })
409        .and_then(|value| recipe_yaml_value_to_string(value, &context));
410    let documentation_url = about
411        .and_then(|section| section.get("documentation"))
412        .and_then(|value| recipe_yaml_value_to_string(value, &context));
413    let license_file = about
414        .and_then(|section| section.get("license_file"))
415        .and_then(|value| recipe_yaml_value_to_string(value, &context));
416
417    let mut dependencies = Vec::new();
418    let mut extra_data: HashMap<String, serde_json::Value> = HashMap::new();
419    if let Some(requirements) = yaml
420        .get("requirements")
421        .and_then(|value| value.as_mapping())
422    {
423        for (scope_key, reqs_value) in requirements {
424            let Some(scope) = scope_key.as_str() else {
425                continue;
426            };
427            let recipe_requirements = extract_recipe_yaml_requirement_strings(reqs_value, &context);
428            if recipe_requirements.is_empty() {
429                continue;
430            }
431
432            for req in &recipe_requirements {
433                if extract_conda_requirement_name(req)
434                    .is_some_and(|name| name == "pip" || name == "python")
435                {
436                    if let Some(arr) = extra_data
437                        .entry(scope.to_string())
438                        .or_insert_with(|| serde_json::Value::Array(vec![]))
439                        .as_array_mut()
440                    {
441                        arr.push(serde_json::Value::String(truncate_field(req.clone())));
442                    }
443                    continue;
444                }
445
446                if let Some(dep) = parse_conda_requirement(req, scope) {
447                    dependencies.push(dep);
448                }
449            }
450        }
451    }
452
453    if let Some(documentation_url) = documentation_url {
454        extra_data.insert(
455            "documentation".to_string(),
456            serde_json::Value::String(documentation_url),
457        );
458    }
459    if let Some(license_file) = license_file {
460        extra_data.insert(
461            "license_file".to_string(),
462            serde_json::Value::String(license_file),
463        );
464    }
465    extra_data.insert("schema_version".to_string(), serde_json::json!(1));
466
467    let mut pkg = default_package_data(Some(DatasourceId::CondaMetaYaml));
468    pkg.package_type = Some(CondaMetaYamlParser::PACKAGE_TYPE);
469    pkg.datasource_id = Some(DatasourceId::CondaMetaYaml);
470    pkg.name = name;
471    pkg.version = version;
472    pkg.purl = build_conda_package_purl(pkg.name.as_deref(), pkg.version.as_deref());
473    pkg.download_url = download_url;
474    pkg.homepage_url = homepage_url;
475    pkg.declared_license_expression = declared_license_expression.map(truncate_field);
476    pkg.declared_license_expression_spdx = declared_license_expression_spdx.map(truncate_field);
477    pkg.license_detections = license_detections;
478    pkg.extracted_license_statement = extracted_license_statement.map(truncate_field);
479    pkg.description = description;
480    pkg.vcs_url = vcs_url;
481    pkg.sha256 = sha256;
482    pkg.dependencies = dependencies;
483    pkg.extra_data = Some(extra_data);
484    pkg
485}
486
487fn extract_recipe_yaml_context(yaml: &Value) -> HashMap<String, String> {
488    let mut context = HashMap::new();
489    let Some(context_mapping) = yaml.get("context").and_then(|value| value.as_mapping()) else {
490        return context;
491    };
492
493    for (key, value) in context_mapping {
494        let Some(key) = key.as_str() else {
495            continue;
496        };
497        if let Some(value) = yaml_value_to_string(value) {
498            context.insert(truncate_field(key.to_string()), truncate_field(value));
499        }
500    }
501
502    context
503}
504
505fn recipe_yaml_value_to_string(value: &Value, context: &HashMap<String, String>) -> Option<String> {
506    let value = yaml_value_to_string(value)?;
507    Some(resolve_recipe_yaml_expressions(&value, context))
508}
509
510fn resolve_recipe_yaml_expressions(value: &str, context: &HashMap<String, String>) -> String {
511    // rattler-build recipe.yaml uses `${{ var }}` substitution from the `context:` block,
512    // optionally with a minijinja filter such as `${{ name|lower }}`. Capture the variable
513    // and an optional single filter so templated package names resolve to real identities
514    // instead of leaking the literal expression into PURLs.
515    let Some(re) = Regex::new(
516        r#"\$\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*(?:\|\s*([A-Za-z_][A-Za-z0-9_]*)\s*)?\}\}"#,
517    )
518    .ok() else {
519        return truncate_field(value.to_string());
520    };
521
522    let resolved = re.replace_all(value, |caps: &regex::Captures| {
523        let Some(substituted) = context.get(&caps[1]) else {
524            // Unknown variable: leave the original expression untouched.
525            return caps[0].to_string();
526        };
527        match caps.get(2).map(|filter| filter.as_str()) {
528            Some("lower") => substituted.to_lowercase(),
529            Some("upper") => substituted.to_uppercase(),
530            // Unknown/no filter: use the substituted value as-is.
531            _ => substituted.clone(),
532        }
533    });
534    truncate_field(resolved.into_owned())
535}
536
537fn extract_recipe_yaml_requirement_strings(
538    value: &Value,
539    context: &HashMap<String, String>,
540) -> Vec<String> {
541    let mut requirements = Vec::new();
542    collect_recipe_yaml_requirement_strings(value, context, &mut requirements);
543    requirements
544}
545
546fn collect_recipe_yaml_requirement_strings(
547    value: &Value,
548    context: &HashMap<String, String>,
549    requirements: &mut Vec<String>,
550) {
551    if let Some(req) = value.as_str() {
552        let resolved = resolve_recipe_yaml_expressions(req, context);
553        if should_keep_recipe_yaml_requirement(&resolved) {
554            requirements.push(resolved);
555        }
556        return;
557    }
558
559    if let Some(items) = value.as_sequence() {
560        let limit = capped_iteration_limit(items.len(), "recipe.yaml requirement items");
561        for item in items.iter().take(limit) {
562            collect_recipe_yaml_requirement_strings(item, context, requirements);
563        }
564        return;
565    }
566
567    if let Some(mapping) = value.as_mapping() {
568        if let Some(then_value) = mapping.get("then") {
569            collect_recipe_yaml_requirement_strings(then_value, context, requirements);
570        }
571        if let Some(else_value) = mapping.get("else") {
572            collect_recipe_yaml_requirement_strings(else_value, context, requirements);
573        }
574    }
575}
576
577fn should_keep_recipe_yaml_requirement(req: &str) -> bool {
578    let trimmed = req.trim();
579    if trimmed.is_empty() {
580        return false;
581    }
582
583    !(trimmed.contains("${{")
584        || trimmed.contains("compiler('")
585        || trimmed.contains("compiler(\"")
586        || trimmed.contains("pin_subpackage(")
587        || trimmed.contains("pin_compatible(")
588        || trimmed.contains("stdlib('")
589        || trimmed.contains("stdlib(\""))
590}
591
592fn normalize_conda_declared_license(
593    statement: Option<&str>,
594) -> (
595    Option<String>,
596    Option<String>,
597    Vec<crate::models::LicenseDetection>,
598) {
599    match statement.map(str::trim).filter(|value| !value.is_empty()) {
600        Some("Apache Software") => build_declared_license_data_from_pair(
601            "apache-2.0",
602            "Apache-2.0",
603            DeclaredLicenseMatchMetadata::single_line("Apache Software"),
604        ),
605        Some("BSD-3-Clause") => build_declared_license_data_from_pair(
606            "bsd-new",
607            "BSD-3-Clause",
608            DeclaredLicenseMatchMetadata::single_line("BSD-3-Clause"),
609        ),
610        other => normalize_spdx_declared_license(other),
611    }
612}
613
614/// Conda environment file (environment.yml, conda.yaml) parser.
615///
616/// Extracts dependencies from Conda environment files used to define reproducible
617/// environments. Supports both Conda and pip dependencies, with channel specifications.
618pub struct CondaEnvironmentYmlParser;
619
620impl PackageParser for CondaEnvironmentYmlParser {
621    const PACKAGE_TYPE: PackageType = PackageType::Conda;
622
623    fn is_match(path: &Path) -> bool {
624        // Python reference: path_patterns = ('*conda*.yaml', '*env*.yaml', '*environment*.yaml')
625        if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
626            let lower = name.to_lowercase();
627            if matches!(lower.as_str(), "meta.yaml" | "meta.yml" | "recipe.yaml") {
628                return false;
629            }
630            let has_condaish_name =
631                lower.contains("conda") || lower.contains("env") || lower.contains("environment");
632            let has_condaish_ancestor = path
633                .ancestors()
634                .skip(1)
635                .filter_map(|ancestor| ancestor.file_name().and_then(|name| name.to_str()))
636                .any(|ancestor| ancestor.to_ascii_lowercase().contains("conda"));
637            (has_condaish_name || has_condaish_ancestor)
638                && (lower.ends_with(".yaml") || lower.ends_with(".yml"))
639        } else {
640            false
641        }
642    }
643
644    fn extract_packages(path: &Path) -> Vec<PackageData> {
645        let contents = match read_file_to_string(path, None) {
646            Ok(c) => c,
647            Err(e) => {
648                warn!("Failed to read {}: {}", path.display(), e);
649                return vec![default_package_data(Some(DatasourceId::CondaYaml))];
650            }
651        };
652
653        if looks_like_template_yaml(&contents) {
654            return Vec::new();
655        }
656
657        let yaml: Value = match yaml_serde::from_str(&contents) {
658            Ok(y) => y,
659            Err(e) => {
660                warn!("Failed to parse YAML in {}: {}", path.display(), e);
661                return vec![default_package_data(Some(DatasourceId::CondaYaml))];
662            }
663        };
664
665        if !looks_like_conda_environment_yaml(&yaml) {
666            return Vec::new();
667        }
668
669        let name = yaml
670            .get("name")
671            .and_then(|v| v.as_str())
672            .map(|s| truncate_field(s.to_string()));
673
674        let dependencies = extract_environment_dependencies(&yaml);
675
676        let mut extra_data = HashMap::new();
677        if let Some(channels) = yaml.get("channels").and_then(|v| v.as_sequence()) {
678            let channels_vec: Vec<String> = channels
679                .iter()
680                .filter_map(|c| c.as_str().map(|s| truncate_field(s.to_string())))
681                .collect();
682            if !channels_vec.is_empty() {
683                extra_data.insert("channels".to_string(), serde_json::json!(channels_vec));
684            }
685        }
686
687        // Environment files are private (not published packages)
688        let mut pkg = default_package_data(Some(DatasourceId::CondaYaml));
689        pkg.package_type = Some(Self::PACKAGE_TYPE);
690        pkg.datasource_id = Some(DatasourceId::CondaYaml);
691        pkg.name = name;
692        pkg.purl = build_conda_package_purl(pkg.name.as_deref(), pkg.version.as_deref());
693        pkg.primary_language = Some(truncate_field("Python".to_string()));
694        pkg.dependencies = dependencies;
695        pkg.is_private = true;
696        if !extra_data.is_empty() {
697            pkg.extra_data = Some(extra_data);
698        }
699        vec![pkg]
700    }
701}
702
703fn looks_like_conda_environment_yaml(yaml: &Value) -> bool {
704    let has_dependencies = yaml
705        .get("dependencies")
706        .and_then(|value| value.as_sequence())
707        .is_some_and(|items| !items.is_empty());
708    let has_channels = yaml
709        .get("channels")
710        .and_then(|value| value.as_sequence())
711        .is_some_and(|items| !items.is_empty());
712    let has_prefix = yaml
713        .get("prefix")
714        .and_then(|value| value.as_str())
715        .is_some_and(|value| !value.trim().is_empty());
716
717    has_dependencies || has_channels || has_prefix
718}
719
720fn looks_like_template_yaml(contents: &str) -> bool {
721    contents
722        .lines()
723        .capped("conda template detection lines")
724        .any(|line| {
725            let trimmed = line.trim_start();
726            trimmed.starts_with("{{") || trimmed.starts_with("{%-") || trimmed.starts_with("{%")
727        })
728}
729
730/// Extract Jinja2-style variables from a Conda meta.yaml
731///
732/// For example, lines like `{% set version = "0.45.0" %}` and
733/// `{% set sha256 = "abc123..." %}` are captured as variables.
734pub fn extract_jinja2_variables(content: &str) -> HashMap<String, String> {
735    let mut variables = HashMap::new();
736
737    for line in content.lines().capped("conda Jinja2 variable lines") {
738        let trimmed = line.trim();
739        if let Some(inner) = extract_jinja_statement(trimmed)
740            && let Some(inner) = inner.strip_prefix("set").map(str::trim)
741            && let Some((key, value)) = inner.split_once('=')
742        {
743            let key = key.trim();
744            let value = value.trim().trim_matches('"').trim_matches('\'');
745            variables.insert(
746                truncate_field(key.to_string()),
747                truncate_field(value.to_string()),
748            );
749        }
750    }
751
752    variables
753}
754
755/// Apply Jinja2 variable substitutions to YAML content
756///
757/// Supports:
758/// - `{{ variable }}` - Simple substitution
759/// - `{{ variable|lower }}` - Lowercase filter
760pub fn apply_jinja2_substitutions(content: &str, variables: &HashMap<String, String>) -> String {
761    let mut result = Vec::new();
762
763    for line in content.lines() {
764        let trimmed = line.trim();
765
766        if extract_jinja_statement(trimmed).is_some() {
767            continue;
768        }
769
770        let mut processed_line = line.to_string();
771
772        // Apply variable substitutions
773        if line.contains("{{") && line.contains("}}") {
774            for (var_name, var_value) in variables {
775                // Handle |lower filter
776                let pattern_lower = format!("{{{{ {}|lower }}}}", var_name);
777                if processed_line.contains(&pattern_lower) {
778                    processed_line =
779                        processed_line.replace(&pattern_lower, &var_value.to_lowercase());
780                }
781
782                // Handle normal substitution
783                let pattern_normal = format!("{{{{ {} }}}}", var_name);
784                processed_line = processed_line.replace(&pattern_normal, var_value);
785            }
786        }
787
788        // Skip lines with unresolved Jinja2 templates (complex expressions we can't handle)
789        if processed_line.contains("{{") {
790            continue;
791        }
792
793        result.push(processed_line);
794    }
795
796    quote_plain_numeric_version_scalars(&result.join("\n"))
797}
798
799fn quote_plain_numeric_version_scalars(content: &str) -> String {
800    let Some(version_re) =
801        Regex::new(r#"^(\s*(?:-\s*)?version:\s*)([0-9]+(?:\.[0-9]+)+)(\s*)$"#).ok()
802    else {
803        return content.to_string();
804    };
805
806    content
807        .lines()
808        .map(|line| {
809            version_re
810                .replace(line, |caps: &regex::Captures| {
811                    format!(r#"{}"{}"{}"#, &caps[1], &caps[2], &caps[3])
812                })
813                .into_owned()
814        })
815        .collect::<Vec<_>>()
816        .join("\n")
817}
818
819/// Parse a Conda requirement string into a Dependency
820///
821/// Format examples:
822/// - `mccortex ==1.0` - Pinned version with space before operator
823/// - `python >=3.6` - Version constraint
824/// - `conda-forge::numpy=1.15.4` - Namespace and pinned version (no space)
825/// - `bwa` - No version specified
826pub fn parse_conda_requirement(req: &str, scope: &str) -> Option<Dependency> {
827    let req = req.trim();
828
829    // Handle namespace prefix (conda-forge::package)
830    let (namespace, channel_url, req_without_ns) = parse_conda_channel_prefix(req);
831
832    // Split on first space to separate name from version constraint
833    let (name_part, version_constraint) =
834        if let Some((name, constraint)) = req_without_ns.split_once(' ') {
835            (name.trim(), Some(constraint.trim()))
836        } else {
837            (req_without_ns, None)
838        };
839
840    // Check for pinned version with `=` (no space): package=1.0
841    let (name, version, is_pinned, extracted_requirement) = if name_part.contains('=') {
842        let parts: Vec<&str> = name_part.splitn(2, '=').collect();
843        let n = parts[0].trim();
844        let v = if parts.len() > 1 {
845            let parsed = parts[1].trim();
846            if parsed.is_empty() {
847                None
848            } else {
849                Some(truncate_field(parsed.to_string()))
850            }
851        } else {
852            None
853        };
854        let req = v
855            .as_ref()
856            .map(|ver| format!("={}", ver))
857            .unwrap_or_default();
858        (n, v, true, Some(truncate_field(req)))
859    } else if let Some(constraint) = version_constraint {
860        let version_opt = if constraint.starts_with("==") {
861            Some(truncate_field(
862                constraint.trim_start_matches("==").trim().to_string(),
863            ))
864        } else {
865            None
866        };
867        (
868            name_part.trim(),
869            version_opt,
870            false,
871            Some(truncate_field(constraint.to_string())),
872        )
873    } else {
874        (name_part.trim(), None, false, Some(String::new()))
875    };
876
877    // Build PURL
878    let purl = build_purl("conda", namespace, name, version.as_deref(), None);
879
880    // Determine is_runtime and is_optional based on scope
881    let (is_runtime, is_optional) = match scope {
882        "run" => (true, false),
883        _ => (false, true), // build, host, test are all optional
884    };
885
886    let mut extra_data = HashMap::new();
887    if let Some(namespace) = namespace {
888        extra_data.insert(
889            "channel".to_string(),
890            serde_json::json!(truncate_field(namespace.to_string())),
891        );
892    }
893    if let Some(channel_url) = channel_url {
894        extra_data.insert(
895            "channel_url".to_string(),
896            serde_json::json!(truncate_field(channel_url.to_string())),
897        );
898    }
899
900    Some(Dependency {
901        purl,
902        extracted_requirement,
903        scope: Some(truncate_field(scope.to_string())),
904        is_runtime: Some(is_runtime),
905        is_optional: Some(is_optional),
906        is_pinned: Some(is_pinned),
907        is_direct: Some(true),
908        resolved_package: None,
909        extra_data: (!extra_data.is_empty()).then_some(extra_data),
910    })
911}
912
913fn extract_environment_dependencies(yaml: &Value) -> Vec<Dependency> {
914    let dependencies = match yaml.get("dependencies").and_then(|v| v.as_sequence()) {
915        Some(d) => d,
916        None => return Vec::new(),
917    };
918
919    let mut deps = Vec::new();
920    let limit = capped_iteration_limit(dependencies.len(), "conda environment dependencies");
921    for dep_value in dependencies.iter().take(limit) {
922        if let Some(dep_str) = dep_value.as_str() {
923            if let Some(dep) = parse_environment_string_dependency(dep_str) {
924                deps.push(dep);
925            }
926        } else if let Some(pip_deps) = dep_value.get("pip").and_then(|v| v.as_sequence()) {
927            deps.extend(extract_pip_dependencies(pip_deps));
928        }
929    }
930    deps
931}
932
933fn parse_environment_string_dependency(dep_str: &str) -> Option<Dependency> {
934    let (namespace, channel_url, dep_without_ns) = parse_conda_channel_prefix(dep_str);
935    create_conda_dependency(namespace, channel_url, dep_without_ns, "dependencies")
936}
937
938fn parse_conda_exact_requirement(req_no_space: &str) -> (Option<String>, Option<String>) {
939    let exact = req_no_space
940        .strip_prefix("==")
941        .or_else(|| req_no_space.strip_prefix('='));
942
943    let Some(exact) = exact else {
944        return (None, None);
945    };
946
947    if exact.is_empty() {
948        return (None, None);
949    }
950
951    match exact.split_once('=') {
952        Some((version, build_string)) if !version.is_empty() => (
953            Some(truncate_field(version.to_string())),
954            (!build_string.is_empty()).then(|| truncate_field(build_string.to_string())),
955        ),
956        _ => (Some(truncate_field(exact.to_string())), None),
957    }
958}
959
960fn parse_conda_channel_prefix(dep_str: &str) -> (Option<&str>, Option<&str>, &str) {
961    if let Some((ns, rest)) = dep_str.rsplit_once("::") {
962        if ns.contains('/') || ns.contains(':') {
963            (None, Some(ns), rest)
964        } else {
965            (Some(ns), None, rest)
966        }
967    } else {
968        (None, None, dep_str)
969    }
970}
971
972fn create_conda_dependency(
973    namespace: Option<&str>,
974    channel_url: Option<&str>,
975    dep_without_ns: &str,
976    scope: &str,
977) -> Option<Dependency> {
978    let dep = dep_without_ns.trim();
979    let name_re = match Regex::new(r"^([A-Za-z0-9_.\-]+)") {
980        Ok(re) => re,
981        Err(_) => return None,
982    };
983
984    let caps = name_re.captures(dep)?;
985    let name_match = caps.get(1)?;
986    let name = name_match.as_str().trim();
987    let rest = dep[name_match.end()..].trim();
988
989    let (version, build_string, is_pinned, extracted_requirement) = if rest.is_empty() {
990        (None, None, false, Some(String::new()))
991    } else {
992        let req_no_space = rest.replace(' ', "");
993        let is_exact = req_no_space.starts_with("=") || req_no_space.starts_with("==");
994        let (parsed_version, parsed_build_string) = if is_exact {
995            parse_conda_exact_requirement(&req_no_space)
996        } else {
997            (None, None)
998        };
999
1000        (
1001            parsed_version,
1002            parsed_build_string,
1003            is_exact,
1004            Some(truncate_field(rest.to_string())),
1005        )
1006    };
1007
1008    if name == "pip" || name == "python" {
1009        return None;
1010    }
1011
1012    let purl = build_purl(
1013        "conda",
1014        namespace,
1015        name,
1016        version.as_deref(),
1017        build_string.as_deref(),
1018    );
1019    let mut extra_data = HashMap::new();
1020    if let Some(namespace) = namespace {
1021        extra_data.insert(
1022            "channel".to_string(),
1023            serde_json::json!(truncate_field(namespace.to_string())),
1024        );
1025    }
1026    if let Some(channel_url) = channel_url {
1027        extra_data.insert(
1028            "channel_url".to_string(),
1029            serde_json::json!(truncate_field(channel_url.to_string())),
1030        );
1031    }
1032    if let Some(build_string) = build_string {
1033        extra_data.insert("build_string".to_string(), serde_json::json!(build_string));
1034    }
1035
1036    Some(Dependency {
1037        purl,
1038        extracted_requirement,
1039        scope: Some(truncate_field(scope.to_string())),
1040        is_runtime: Some(true),
1041        is_optional: Some(false),
1042        is_pinned: Some(is_pinned),
1043        is_direct: Some(true),
1044        resolved_package: None,
1045        extra_data: (!extra_data.is_empty()).then_some(extra_data),
1046    })
1047}
1048
1049fn extract_pip_dependencies(pip_deps: &[Value]) -> Vec<Dependency> {
1050    let limit = capped_iteration_limit(pip_deps.len(), "conda pip dependencies");
1051    pip_deps
1052        .iter()
1053        .take(limit)
1054        .filter_map(|pip_dep| {
1055            if let Some(pip_req_str) = pip_dep.as_str()
1056                && let Some(parsed_req) =
1057                    crate::parsers::pep508::parse_pep508_requirement(pip_req_str)
1058            {
1059                create_pip_dependency(parsed_req, "dependencies", Some(pip_req_str))
1060            } else {
1061                None
1062            }
1063        })
1064        .collect()
1065}
1066
1067fn create_pip_dependency(
1068    parsed_req: crate::parsers::pep508::Pep508Requirement,
1069    scope: &str,
1070    raw_requirement: Option<&str>,
1071) -> Option<Dependency> {
1072    let name = truncate_field(parsed_req.name);
1073
1074    if name == "pip" || name == "python" {
1075        return None;
1076    }
1077
1078    let specs = if parsed_req.is_name_at_url {
1079        parsed_req
1080            .url
1081            .as_ref()
1082            .map(|url| truncate_field(url.clone()))
1083    } else {
1084        parsed_req.specifiers.clone()
1085    };
1086
1087    let extracted_requirement = if let Some(raw) = raw_requirement {
1088        let raw = raw.trim();
1089        let suffix = raw.strip_prefix(&name).unwrap_or(raw).trim().to_string();
1090        Some(truncate_field(suffix))
1091    } else {
1092        Some(truncate_field(specs.clone().unwrap_or_default()))
1093    };
1094
1095    let version = specs.as_ref().and_then(|spec_str| {
1096        if spec_str.starts_with("==") {
1097            Some(truncate_field(
1098                spec_str.trim_start_matches("==").to_string(),
1099            ))
1100        } else {
1101            None
1102        }
1103    });
1104
1105    let is_pinned = specs.as_ref().map(|s| s.contains("==")).unwrap_or(false);
1106    let purl = build_purl("pypi", None, &name, version.as_deref(), None);
1107
1108    Some(Dependency {
1109        purl,
1110        extracted_requirement,
1111        scope: Some(truncate_field(scope.to_string())),
1112        is_runtime: Some(true),
1113        is_optional: Some(false),
1114        is_pinned: Some(is_pinned),
1115        is_direct: Some(true),
1116        resolved_package: None,
1117        extra_data: None,
1118    })
1119}