1use std::collections::HashMap;
29use std::fs;
30use std::path::Path;
31
32use crate::parser_warn as warn;
33use regex::Regex;
34use yaml_serde::Value;
35
36use crate::models::{DatasourceId, Dependency, PackageData, PackageType};
37
38use super::PackageParser;
39use super::license_normalization::{
40 DeclaredLicenseMatchMetadata, build_declared_license_data_from_pair,
41 normalize_spdx_declared_license,
42};
43
44fn default_package_data(datasource_id: Option<DatasourceId>) -> PackageData {
45 PackageData {
46 package_type: Some(CondaMetaYamlParser::PACKAGE_TYPE),
47 datasource_id,
48 ..Default::default()
49 }
50}
51
52pub(crate) fn build_purl(
54 package_type: &str,
55 namespace: Option<&str>,
56 name: &str,
57 version: Option<&str>,
58 _qualifiers: Option<&str>,
59 _subpath: Option<&str>,
60 _extras: Option<&str>,
61) -> Option<String> {
62 let purl = match package_type {
63 "conda" => {
64 if let Some(ns) = namespace {
65 match version {
66 Some(v) => format!("pkg:conda/{}/{}@{}", ns, name, v),
67 None => format!("pkg:conda/{}/{}", ns, name),
68 }
69 } else {
70 match version {
71 Some(v) => format!("pkg:conda/{}@{}", name, v),
72 None => format!("pkg:conda/{}", name),
73 }
74 }
75 }
76 "pypi" => match version {
77 Some(v) => format!("pkg:pypi/{}@{}", name, v),
78 None => format!("pkg:pypi/{}", name),
79 },
80 _ => format!("pkg:{}/{}", package_type, name),
81 };
82 Some(purl)
83}
84
85fn build_conda_package_purl(name: Option<&str>, version: Option<&str>) -> Option<String> {
86 let name = name?;
87 build_purl("conda", None, name, version, None, None, None)
88}
89
90fn yaml_value_to_string(value: &Value) -> Option<String> {
91 match value {
92 Value::String(s) => Some(s.clone()),
93 Value::Number(n) => Some(n.to_string()),
94 Value::Bool(b) => Some(b.to_string()),
95 _ => None,
96 }
97}
98
99fn extract_conda_requirement_name(req: &str) -> Option<String> {
100 let req = req.trim();
101 if req.is_empty() {
102 return None;
103 }
104
105 let req_without_ns = req.rsplit_once("::").map(|(_, rest)| rest).unwrap_or(req);
106
107 let name = req_without_ns
108 .split_whitespace()
109 .next()
110 .unwrap_or(req_without_ns)
111 .split(['=', '<', '>', '!', '~'])
112 .next()
113 .unwrap_or(req_without_ns)
114 .trim();
115
116 if name.is_empty() {
117 None
118 } else {
119 Some(name.to_string())
120 }
121}
122
123pub struct CondaMetaYamlParser;
129
130impl PackageParser for CondaMetaYamlParser {
131 const PACKAGE_TYPE: PackageType = PackageType::Conda;
132
133 fn is_match(path: &Path) -> bool {
134 path.file_name()
136 .is_some_and(|name| name == "meta.yaml" || name == "meta.yml")
137 }
138
139 fn extract_packages(path: &Path) -> Vec<PackageData> {
140 let contents = match fs::read_to_string(path) {
141 Ok(c) => c,
142 Err(e) => {
143 warn!("Failed to read {}: {}", path.display(), e);
144 return vec![default_package_data(Some(DatasourceId::CondaMetaYaml))];
145 }
146 };
147
148 let variables = extract_jinja2_variables(&contents);
150 let processed_yaml = apply_jinja2_substitutions(&contents, &variables);
151
152 let yaml: Value = match yaml_serde::from_str(&processed_yaml) {
154 Ok(y) => y,
155 Err(e) => {
156 warn!("Failed to parse YAML in {}: {}", path.display(), e);
157 return vec![default_package_data(Some(DatasourceId::CondaMetaYaml))];
158 }
159 };
160
161 let package_element = yaml.get("package").and_then(|v| v.as_mapping());
162 let name = package_element
163 .and_then(|p| p.get("name"))
164 .and_then(yaml_value_to_string);
165
166 let version = package_element
167 .and_then(|p| p.get("version"))
168 .and_then(yaml_value_to_string);
169
170 let source = yaml.get("source").and_then(|v| v.as_mapping());
171 let download_url = source
172 .and_then(|s| s.get("url"))
173 .and_then(|v| v.as_str())
174 .map(String::from);
175
176 let sha256 = source
177 .and_then(|s| s.get("sha256"))
178 .and_then(|v| v.as_str())
179 .map(String::from);
180
181 let about = yaml.get("about").and_then(|v| v.as_mapping());
182 let homepage_url = about
183 .and_then(|a| a.get("home"))
184 .and_then(|v| v.as_str())
185 .map(String::from);
186
187 let extracted_license_statement = about
188 .and_then(|a| a.get("license"))
189 .and_then(|v| v.as_str())
190 .map(String::from);
191 let (declared_license_expression, declared_license_expression_spdx, license_detections) =
192 normalize_conda_declared_license(extracted_license_statement.as_deref());
193
194 let description = about
195 .and_then(|a| a.get("summary"))
196 .and_then(|v| v.as_str())
197 .map(String::from);
198
199 let vcs_url = about
200 .and_then(|a| a.get("dev_url"))
201 .and_then(|v| v.as_str())
202 .map(String::from);
203 let license_file = about
204 .and_then(|a| a.get("license_file"))
205 .and_then(|v| v.as_str())
206 .map(str::trim)
207 .filter(|value| !value.is_empty())
208 .map(String::from);
209
210 let mut dependencies = Vec::new();
212 let mut extra_data: HashMap<String, serde_json::Value> = HashMap::new();
213
214 if let Some(requirements) = yaml.get("requirements").and_then(|v| v.as_mapping()) {
215 for (scope_key, reqs_value) in requirements {
216 let scope = scope_key.as_str().unwrap_or("unknown");
217 if let Some(reqs) = reqs_value.as_sequence() {
218 for req in reqs {
219 if let Some(req_str) = req.as_str()
220 && let Some(dep) = parse_conda_requirement(req_str, scope)
221 {
222 if extract_conda_requirement_name(req_str)
224 .is_some_and(|n| n == "pip" || n == "python")
225 {
226 if let Some(arr) = extra_data
227 .entry(scope.to_string())
228 .or_insert_with(|| serde_json::Value::Array(vec![]))
229 .as_array_mut()
230 {
231 arr.push(serde_json::Value::String(req_str.to_string()))
232 }
233 } else {
234 dependencies.push(dep);
235 }
236 }
237 }
238 }
239 }
240 }
241
242 let mut pkg = default_package_data(Some(DatasourceId::CondaMetaYaml));
243 pkg.package_type = Some(Self::PACKAGE_TYPE);
244 pkg.datasource_id = Some(DatasourceId::CondaMetaYaml);
245 pkg.name = name;
246 pkg.version = version;
247 pkg.purl = build_conda_package_purl(pkg.name.as_deref(), pkg.version.as_deref());
248 pkg.download_url = download_url;
249 pkg.homepage_url = homepage_url;
250 pkg.declared_license_expression = declared_license_expression;
251 pkg.declared_license_expression_spdx = declared_license_expression_spdx;
252 pkg.license_detections = license_detections;
253 pkg.extracted_license_statement = extracted_license_statement;
254 pkg.description = description;
255 pkg.vcs_url = vcs_url;
256 pkg.sha256 = sha256;
257 pkg.dependencies = dependencies;
258 if let Some(license_file) = license_file {
259 extra_data.insert(
260 "license_file".to_string(),
261 serde_json::Value::String(license_file),
262 );
263 }
264 if !extra_data.is_empty() {
265 pkg.extra_data = Some(extra_data);
266 }
267 vec![pkg]
268 }
269}
270
271fn normalize_conda_declared_license(
272 statement: Option<&str>,
273) -> (
274 Option<String>,
275 Option<String>,
276 Vec<crate::models::LicenseDetection>,
277) {
278 match statement.map(str::trim).filter(|value| !value.is_empty()) {
279 Some("Apache Software") => build_declared_license_data_from_pair(
280 "apache-2.0",
281 "Apache-2.0",
282 DeclaredLicenseMatchMetadata::single_line("Apache Software"),
283 ),
284 Some("BSD-3-Clause") => build_declared_license_data_from_pair(
285 "bsd-new",
286 "BSD-3-Clause",
287 DeclaredLicenseMatchMetadata::single_line("BSD-3-Clause"),
288 ),
289 other => normalize_spdx_declared_license(other),
290 }
291}
292
293pub struct CondaEnvironmentYmlParser;
298
299impl PackageParser for CondaEnvironmentYmlParser {
300 const PACKAGE_TYPE: PackageType = PackageType::Conda;
301
302 fn is_match(path: &Path) -> bool {
303 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
305 let lower = name.to_lowercase();
306 (lower.contains("conda") || lower.contains("env") || lower.contains("environment"))
307 && (lower.ends_with(".yaml") || lower.ends_with(".yml"))
308 } else {
309 false
310 }
311 }
312
313 fn extract_packages(path: &Path) -> Vec<PackageData> {
314 let contents = match fs::read_to_string(path) {
315 Ok(c) => c,
316 Err(e) => {
317 warn!("Failed to read {}: {}", path.display(), e);
318 return vec![default_package_data(Some(DatasourceId::CondaYaml))];
319 }
320 };
321
322 let yaml: Value = match yaml_serde::from_str(&contents) {
323 Ok(y) => y,
324 Err(e) => {
325 warn!("Failed to parse YAML in {}: {}", path.display(), e);
326 return vec![default_package_data(Some(DatasourceId::CondaYaml))];
327 }
328 };
329
330 if !looks_like_conda_environment_yaml(&yaml) {
331 return Vec::new();
332 }
333
334 let name = yaml.get("name").and_then(|v| v.as_str()).map(String::from);
335
336 let dependencies = extract_environment_dependencies(&yaml);
337
338 let mut extra_data = HashMap::new();
339 if let Some(channels) = yaml.get("channels").and_then(|v| v.as_sequence()) {
340 let channels_vec: Vec<String> = channels
341 .iter()
342 .filter_map(|c| c.as_str().map(String::from))
343 .collect();
344 if !channels_vec.is_empty() {
345 extra_data.insert("channels".to_string(), serde_json::json!(channels_vec));
346 }
347 }
348
349 let mut pkg = default_package_data(Some(DatasourceId::CondaYaml));
351 pkg.package_type = Some(Self::PACKAGE_TYPE);
352 pkg.datasource_id = Some(DatasourceId::CondaYaml);
353 pkg.name = name;
354 pkg.purl = build_conda_package_purl(pkg.name.as_deref(), pkg.version.as_deref());
355 pkg.primary_language = Some("Python".to_string());
356 pkg.dependencies = dependencies;
357 pkg.is_private = true;
358 if !extra_data.is_empty() {
359 pkg.extra_data = Some(extra_data);
360 }
361 vec![pkg]
362 }
363}
364
365fn looks_like_conda_environment_yaml(yaml: &Value) -> bool {
366 let has_dependencies = yaml
367 .get("dependencies")
368 .and_then(|value| value.as_sequence())
369 .is_some_and(|items| !items.is_empty());
370 let has_channels = yaml
371 .get("channels")
372 .and_then(|value| value.as_sequence())
373 .is_some_and(|items| !items.is_empty());
374 let has_prefix = yaml
375 .get("prefix")
376 .and_then(|value| value.as_str())
377 .is_some_and(|value| !value.trim().is_empty());
378
379 has_dependencies || has_channels || has_prefix
380}
381
382pub fn extract_jinja2_variables(content: &str) -> HashMap<String, String> {
390 let mut variables = HashMap::new();
391
392 for line in content.lines() {
393 let trimmed = line.trim();
394 if trimmed.starts_with("{%") && trimmed.ends_with("%}") && trimmed.contains('=') {
395 let inner = trimmed
397 .trim_start_matches("{%")
398 .trim_end_matches("%}")
399 .trim()
400 .trim_start_matches("set")
401 .trim();
402
403 if let Some((key, value)) = inner.split_once('=') {
405 let key = key.trim();
406 let value = value.trim().trim_matches('"').trim_matches('\'');
407 variables.insert(key.to_string(), value.to_string());
408 }
409 }
410 }
411
412 variables
413}
414
415pub fn apply_jinja2_substitutions(content: &str, variables: &HashMap<String, String>) -> String {
421 let mut result = Vec::new();
422
423 for line in content.lines() {
424 let trimmed = line.trim();
425
426 if trimmed.starts_with("{%") && trimmed.ends_with("%}") && trimmed.contains('=') {
428 continue;
429 }
430
431 let mut processed_line = line.to_string();
432
433 if line.contains("{{") && line.contains("}}") {
435 for (var_name, var_value) in variables {
436 let pattern_lower = format!("{{{{ {}|lower }}}}", var_name);
438 if processed_line.contains(&pattern_lower) {
439 processed_line =
440 processed_line.replace(&pattern_lower, &var_value.to_lowercase());
441 }
442
443 let pattern_normal = format!("{{{{ {} }}}}", var_name);
445 processed_line = processed_line.replace(&pattern_normal, var_value);
446 }
447 }
448
449 if processed_line.contains("{{") {
451 continue;
452 }
453
454 result.push(processed_line);
455 }
456
457 result.join("\n")
458}
459
460pub fn parse_conda_requirement(req: &str, scope: &str) -> Option<Dependency> {
468 let req = req.trim();
469
470 let (namespace, channel_url, req_without_ns) = parse_conda_channel_prefix(req);
472
473 let (name_part, version_constraint) =
475 if let Some((name, constraint)) = req_without_ns.split_once(' ') {
476 (name.trim(), Some(constraint.trim()))
477 } else {
478 (req_without_ns, None)
479 };
480
481 let (name, version, is_pinned, extracted_requirement) = if name_part.contains('=') {
483 let parts: Vec<&str> = name_part.splitn(2, '=').collect();
484 let n = parts[0].trim();
485 let v = if parts.len() > 1 {
486 let parsed = parts[1].trim();
487 if parsed.is_empty() {
488 None
489 } else {
490 Some(parsed.to_string())
491 }
492 } else {
493 None
494 };
495 let req = v
496 .as_ref()
497 .map(|ver| format!("={}", ver))
498 .unwrap_or_default();
499 (n, v, true, Some(req))
500 } else if let Some(constraint) = version_constraint {
501 let version_opt = if constraint.starts_with("==") {
503 Some(constraint.trim_start_matches("==").trim().to_string())
504 } else {
505 None
506 };
507 (
508 name_part.trim(),
509 version_opt,
510 false,
511 Some(constraint.to_string()),
512 )
513 } else {
514 (name_part.trim(), None, false, Some(String::new()))
515 };
516
517 let purl = build_purl(
519 "conda",
520 namespace,
521 name,
522 version.as_deref(),
523 None,
524 None,
525 None,
526 );
527
528 let (is_runtime, is_optional) = match scope {
530 "run" => (true, false),
531 _ => (false, true), };
533
534 let mut extra_data = HashMap::new();
535 if let Some(namespace) = namespace {
536 extra_data.insert("channel".to_string(), serde_json::json!(namespace));
537 }
538 if let Some(channel_url) = channel_url {
539 extra_data.insert("channel_url".to_string(), serde_json::json!(channel_url));
540 }
541
542 Some(Dependency {
543 purl,
544 extracted_requirement,
545 scope: Some(scope.to_string()),
546 is_runtime: Some(is_runtime),
547 is_optional: Some(is_optional),
548 is_pinned: Some(is_pinned),
549 is_direct: Some(true),
550 resolved_package: None,
551 extra_data: (!extra_data.is_empty()).then_some(extra_data),
552 })
553}
554
555fn extract_environment_dependencies(yaml: &Value) -> Vec<Dependency> {
556 let dependencies = match yaml.get("dependencies").and_then(|v| v.as_sequence()) {
557 Some(d) => d,
558 None => return Vec::new(),
559 };
560
561 let mut deps = Vec::new();
562 for dep_value in dependencies {
563 if let Some(dep_str) = dep_value.as_str() {
564 if let Some(dep) = parse_environment_string_dependency(dep_str) {
565 deps.push(dep);
566 }
567 } else if let Some(pip_deps) = dep_value.get("pip").and_then(|v| v.as_sequence()) {
568 deps.extend(extract_pip_dependencies(pip_deps));
569 }
570 }
571 deps
572}
573
574fn parse_environment_string_dependency(dep_str: &str) -> Option<Dependency> {
575 let (namespace, channel_url, dep_without_ns) = parse_conda_channel_prefix(dep_str);
576 create_conda_dependency(namespace, channel_url, dep_without_ns, "dependencies")
577}
578
579fn parse_conda_channel_prefix(dep_str: &str) -> (Option<&str>, Option<&str>, &str) {
580 if let Some((ns, rest)) = dep_str.rsplit_once("::") {
581 if ns.contains('/') || ns.contains(':') {
582 (None, Some(ns), rest)
583 } else {
584 (Some(ns), None, rest)
585 }
586 } else {
587 (None, None, dep_str)
588 }
589}
590
591fn create_conda_dependency(
592 namespace: Option<&str>,
593 channel_url: Option<&str>,
594 dep_without_ns: &str,
595 scope: &str,
596) -> Option<Dependency> {
597 let dep = dep_without_ns.trim();
598 let name_re = match Regex::new(r"^([A-Za-z0-9_.\-]+)") {
599 Ok(re) => re,
600 Err(_) => return None,
601 };
602
603 let caps = name_re.captures(dep)?;
604 let name_match = caps.get(1)?;
605 let name = name_match.as_str().trim();
606 let rest = dep[name_match.end()..].trim();
607
608 let (version, is_pinned, extracted_requirement) = if rest.is_empty() {
609 (None, false, Some(String::new()))
610 } else {
611 let req_no_space = rest.replace(' ', "");
612 let is_exact = req_no_space.starts_with("=") || req_no_space.starts_with("==");
613 let parsed_version = if is_exact {
614 Some(
615 req_no_space
616 .trim_start_matches('=')
617 .trim_start_matches('=')
618 .to_string(),
619 )
620 } else {
621 None
622 };
623
624 (parsed_version, is_exact, Some(rest.to_string()))
625 };
626
627 if name == "pip" || name == "python" {
628 return None;
629 }
630
631 let purl = build_purl(
632 "conda",
633 namespace,
634 name,
635 version.as_deref(),
636 None,
637 None,
638 None,
639 );
640 let mut extra_data = HashMap::new();
641 if let Some(namespace) = namespace {
642 extra_data.insert("channel".to_string(), serde_json::json!(namespace));
643 }
644 if let Some(channel_url) = channel_url {
645 extra_data.insert("channel_url".to_string(), serde_json::json!(channel_url));
646 }
647
648 Some(Dependency {
649 purl,
650 extracted_requirement,
651 scope: Some(scope.to_string()),
652 is_runtime: Some(true),
653 is_optional: Some(false),
654 is_pinned: Some(is_pinned),
655 is_direct: Some(true),
656 resolved_package: None,
657 extra_data: (!extra_data.is_empty()).then_some(extra_data),
658 })
659}
660
661fn extract_pip_dependencies(pip_deps: &[Value]) -> Vec<Dependency> {
662 pip_deps
663 .iter()
664 .filter_map(|pip_dep| {
665 if let Some(pip_req_str) = pip_dep.as_str()
666 && let Ok(parsed_req) = pip_req_str.parse::<pep508_rs::Requirement>()
667 {
668 create_pip_dependency(parsed_req, "dependencies", Some(pip_req_str))
669 } else {
670 None
671 }
672 })
673 .collect()
674}
675
676fn create_pip_dependency(
677 parsed_req: pep508_rs::Requirement,
678 scope: &str,
679 raw_requirement: Option<&str>,
680) -> Option<Dependency> {
681 let name = parsed_req.name.to_string();
682
683 if name == "pip" || name == "python" {
684 return None;
685 }
686
687 let specs = parsed_req.version_or_url.as_ref().map(|v| match v {
688 pep508_rs::VersionOrUrl::VersionSpecifier(spec) => spec.to_string(),
689 pep508_rs::VersionOrUrl::Url(url) => url.to_string(),
690 });
691
692 let extracted_requirement = if let Some(raw) = raw_requirement {
693 let raw = raw.trim();
694 let suffix = raw.strip_prefix(&name).unwrap_or(raw).trim().to_string();
695 Some(suffix)
696 } else {
697 Some(specs.clone().unwrap_or_default())
698 };
699
700 let version = specs.as_ref().and_then(|spec_str| {
701 if spec_str.starts_with("==") {
702 Some(spec_str.trim_start_matches("==").to_string())
703 } else {
704 None
705 }
706 });
707
708 let is_pinned = specs.as_ref().map(|s| s.contains("==")).unwrap_or(false);
709 let purl = build_purl("pypi", None, &name, version.as_deref(), None, None, None);
710
711 Some(Dependency {
712 purl,
713 extracted_requirement,
714 scope: Some(scope.to_string()),
715 is_runtime: Some(true),
716 is_optional: Some(false),
717 is_pinned: Some(is_pinned),
718 is_direct: Some(true),
719 resolved_package: None,
720 extra_data: None,
721 })
722}
723
724crate::register_parser!(
725 "Conda package manifest and environment file",
726 &[
727 "**/meta.yaml",
728 "**/meta.yml",
729 "**/environment.yml",
730 "**/environment.yaml",
731 "**/env.yaml",
732 "**/env.yml",
733 "**/conda.yaml",
734 "**/conda.yml",
735 "**/*conda*.yaml",
736 "**/*conda*.yml",
737 "**/*env*.yaml",
738 "**/*env*.yml",
739 "**/*environment*.yaml",
740 "**/*environment*.yml"
741 ],
742 "conda",
743 "Python",
744 Some("https://docs.conda.io/"),
745);