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