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