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