Skip to main content

provenant/parsers/
conda.rs

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