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