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