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