use super::{Dependency, DependencyParser};
use check_updates_core::VersionSpec;
use anyhow::{Context, Result};
use serde_yaml::Value;
use std::fs;
use std::path::Path;
pub struct CondaParser;
impl Default for CondaParser {
fn default() -> Self {
Self::new()
}
}
impl CondaParser {
pub fn new() -> Self {
Self
}
fn parse_conda_dependency(dep_str: &str) -> Option<(String, VersionSpec)> {
let dep_str = dep_str.trim();
if dep_str.is_empty() || dep_str.starts_with('#') {
return None;
}
if let Some(idx) = dep_str.find(">=") {
let name = dep_str[..idx].trim().to_lowercase();
let version_str = dep_str[idx + 2..].trim();
return match VersionSpec::parse(&format!(">={version_str}")) {
Ok(spec) => Some((name, spec)),
Err(_) => Some((name, VersionSpec::Any)),
};
}
if let Some(idx) = dep_str.find("<=") {
let name = dep_str[..idx].trim().to_lowercase();
let version_str = dep_str[idx + 2..].trim();
return match VersionSpec::parse(&format!("<={version_str}")) {
Ok(spec) => Some((name, spec)),
Err(_) => Some((name, VersionSpec::Any)),
};
}
if let Some(idx) = dep_str.find("!=") {
let name = dep_str[..idx].trim().to_lowercase();
let version_str = dep_str[idx + 2..].trim();
return match VersionSpec::parse(&format!("!={version_str}")) {
Ok(spec) => Some((name, spec)),
Err(_) => Some((name, VersionSpec::Any)),
};
}
if let Some(idx) = dep_str.find('>') {
let name = dep_str[..idx].trim().to_lowercase();
let version_str = dep_str[idx + 1..].trim();
return match VersionSpec::parse(&format!(">{version_str}")) {
Ok(spec) => Some((name, spec)),
Err(_) => Some((name, VersionSpec::Any)),
};
}
if let Some(idx) = dep_str.find('<') {
let name = dep_str[..idx].trim().to_lowercase();
let version_str = dep_str[idx + 1..].trim();
return match VersionSpec::parse(&format!("<{version_str}")) {
Ok(spec) => Some((name, spec)),
Err(_) => Some((name, VersionSpec::Any)),
};
}
if let Some(idx) = dep_str.find('=') {
let name = dep_str[..idx].trim().to_lowercase();
let version_str = dep_str[idx + 1..].trim();
return match VersionSpec::parse(&format!("=={version_str}")) {
Ok(spec) => Some((name, spec)),
Err(_) => Some((name, VersionSpec::Any)),
};
}
let name = dep_str.to_lowercase();
Some((name, VersionSpec::Any))
}
fn parse_pip_dependency(dep_str: &str) -> Option<(String, VersionSpec)> {
let dep_str = dep_str.trim();
if dep_str.is_empty() || dep_str.starts_with('#') {
return None;
}
let operators = ["==", ">=", "<=", "~=", "!=", "<", ">", "^", "~"];
let mut split_pos = None;
for op in &operators {
if let Some(pos) = dep_str.find(op)
&& split_pos.is_none_or(|sp| pos < sp) {
split_pos = Some(pos);
}
}
if let Some(pos) = split_pos {
let name = dep_str[..pos].trim().to_lowercase();
let version_str = dep_str[pos..].trim();
return match VersionSpec::parse(version_str) {
Ok(spec) => Some((name, spec)),
Err(_) => Some((name, VersionSpec::Any)),
};
}
let name = dep_str.to_lowercase();
Some((name, VersionSpec::Any))
}
}
impl DependencyParser for CondaParser {
fn parse(&self, path: &Path) -> Result<Vec<Dependency>> {
let content = fs::read_to_string(path)
.context(format!("Failed to read file: {}", path.display()))?;
let yaml: Value = serde_yaml::from_str(&content)
.context(format!("Failed to parse YAML: {}", path.display()))?;
let mut dependencies = Vec::new();
if let Some(deps) = yaml.get("dependencies").and_then(|v| v.as_sequence()) {
for (idx, dep) in deps.iter().enumerate() {
let line_number = idx + 2;
if let Some(dep_str) = dep.as_str() {
if let Some((name, version_spec)) = Self::parse_conda_dependency(dep_str) {
dependencies.push(Dependency {
name,
version_spec,
source_file: path.to_path_buf(),
line_number,
original_line: format!(" - {dep_str}"),
});
}
} else if let Some(pip_section) = dep.as_mapping() {
if let Some(pip_deps) = pip_section.get("pip").and_then(|v| v.as_sequence()) {
for (pip_idx, pip_dep) in pip_deps.iter().enumerate() {
if let Some(pip_dep_str) = pip_dep.as_str()
&& let Some((name, version_spec)) = Self::parse_pip_dependency(pip_dep_str) {
dependencies.push(Dependency {
name,
version_spec,
source_file: path.to_path_buf(),
line_number: line_number + pip_idx + 1, original_line: format!(" - {pip_dep_str}"),
});
}
}
}
}
}
}
Ok(dependencies)
}
fn can_parse(&self, path: &Path) -> bool {
path.file_name()
.and_then(|n| n.to_str())
.map(|n| n == "environment.yml" || n == "environment.yaml")
.unwrap_or(false)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use std::path::PathBuf;
use tempfile::NamedTempFile;
#[test]
fn test_can_parse() {
let parser = CondaParser::new();
assert!(parser.can_parse(&PathBuf::from("environment.yml")));
assert!(parser.can_parse(&PathBuf::from("environment.yaml")));
assert!(!parser.can_parse(&PathBuf::from("requirements.txt")));
assert!(!parser.can_parse(&PathBuf::from("pyproject.toml")));
}
#[test]
fn test_parse_conda_dependency() {
let (name, spec) = CondaParser::parse_conda_dependency("numpy").unwrap();
assert_eq!(name, "numpy");
assert!(matches!(spec, VersionSpec::Any));
let (name, spec) = CondaParser::parse_conda_dependency("numpy=1.24.0").unwrap();
assert_eq!(name, "numpy");
assert!(matches!(spec, VersionSpec::Pinned(_)));
let (name, spec) = CondaParser::parse_conda_dependency("numpy>=1.24.0").unwrap();
assert_eq!(name, "numpy");
assert!(matches!(spec, VersionSpec::Minimum(_)));
let (name, spec) = CondaParser::parse_conda_dependency("python=3.9.*").unwrap();
assert_eq!(name, "python");
assert!(matches!(spec, VersionSpec::Wildcard { .. }));
}
#[test]
fn test_parse_pip_dependency() {
let (name, spec) = CondaParser::parse_pip_dependency("requests").unwrap();
assert_eq!(name, "requests");
assert!(matches!(spec, VersionSpec::Any));
let (name, spec) = CondaParser::parse_pip_dependency("requests==2.28.0").unwrap();
assert_eq!(name, "requests");
assert!(matches!(spec, VersionSpec::Pinned(_)));
let (name, spec) = CondaParser::parse_pip_dependency("numpy>=1.24.0,<2.0.0").unwrap();
assert_eq!(name, "numpy");
assert!(matches!(spec, VersionSpec::Range { .. }));
let (name, spec) = CondaParser::parse_pip_dependency("flask~=2.0.0").unwrap();
assert_eq!(name, "flask");
assert!(matches!(spec, VersionSpec::Compatible(_)));
}
#[test]
fn test_parse_environment_yml() {
let yaml_content = r#"
name: myenv
channels:
- conda-forge
- defaults
dependencies:
- python=3.9.*
- numpy=1.24.0
- pandas>=1.5.0
- scikit-learn
- pip:
- requests==2.28.0
- flask>=2.0.0,<3.0.0
- django
"#;
let mut temp_file = NamedTempFile::new().unwrap();
write!(temp_file, "{}", yaml_content).unwrap();
let path = temp_file.path().to_path_buf();
let parser = CondaParser::new();
let dependencies = parser.parse(&path).unwrap();
assert_eq!(dependencies.len(), 7);
let python_dep = dependencies.iter().find(|d| d.name == "python").unwrap();
assert!(matches!(python_dep.version_spec, VersionSpec::Wildcard { .. }));
let numpy_dep = dependencies.iter().find(|d| d.name == "numpy").unwrap();
assert!(matches!(numpy_dep.version_spec, VersionSpec::Pinned(_)));
let pandas_dep = dependencies.iter().find(|d| d.name == "pandas").unwrap();
assert!(matches!(pandas_dep.version_spec, VersionSpec::Minimum(_)));
let sklearn_dep = dependencies.iter().find(|d| d.name == "scikit-learn").unwrap();
assert!(matches!(sklearn_dep.version_spec, VersionSpec::Any));
let requests_dep = dependencies.iter().find(|d| d.name == "requests").unwrap();
assert!(matches!(requests_dep.version_spec, VersionSpec::Pinned(_)));
let flask_dep = dependencies.iter().find(|d| d.name == "flask").unwrap();
assert!(matches!(flask_dep.version_spec, VersionSpec::Range { .. }));
let django_dep = dependencies.iter().find(|d| d.name == "django").unwrap();
assert!(matches!(django_dep.version_spec, VersionSpec::Any));
}
#[test]
fn test_parse_environment_yaml() {
let yaml_content = r#"
dependencies:
- numpy=1.24.0
"#;
let mut temp_file = NamedTempFile::new().unwrap();
write!(temp_file, "{}", yaml_content).unwrap();
let temp_path = temp_file.path().to_path_buf();
let yaml_path = temp_path.parent().unwrap().join("environment.yaml");
std::fs::write(&yaml_path, yaml_content).unwrap();
let parser = CondaParser::new();
assert!(parser.can_parse(&yaml_path));
let dependencies = parser.parse(&yaml_path).unwrap();
assert_eq!(dependencies.len(), 1);
assert_eq!(dependencies[0].name, "numpy");
std::fs::remove_file(&yaml_path).ok();
}
#[test]
fn test_empty_dependencies() {
let yaml_content = r#"
name: myenv
dependencies: []
"#;
let mut temp_file = NamedTempFile::new().unwrap();
write!(temp_file, "{}", yaml_content).unwrap();
let path = temp_file.path().to_path_buf();
let parser = CondaParser::new();
let dependencies = parser.parse(&path).unwrap();
assert_eq!(dependencies.len(), 0);
}
}