diff --git a/crates/pixi/tests/integration_rust/add_tests.rs b/crates/pixi/tests/integration_rust/add_tests.rs index 416ffbbcba..00bc222569 100644 --- a/crates/pixi/tests/integration_rust/add_tests.rs +++ b/crates/pixi/tests/integration_rust/add_tests.rs @@ -1,1201 +1,1272 @@ -use std::str::FromStr; - -use pixi_cli::cli_config::GitRev; -use pixi_consts::consts; -use pixi_core::{DependencyType, Workspace}; -use pixi_manifest::{FeaturesExt, SpecType}; -use pixi_pypi_spec::{PixiPypiSpec, PypiPackageName, VersionOrStar}; -use rattler_conda_types::{PackageName, Platform}; -use tempfile::TempDir; -use url::Url; - -use pixi_build_backend_passthrough::PassthroughBackend; -use pixi_build_frontend::BackendOverride; - -use crate::common::{ - LockFileExt, PixiControl, - builders::{HasDependencyConfig, HasLockFileUpdateConfig, HasNoInstallConfig}, -}; -use crate::setup_tracing; -use pixi_test_utils::{GitRepoFixture, MockRepoData, Package}; - -/// Test add functionality for different types of packages. -/// Run, dev, build -#[tokio::test] -async fn add_functionality() { - setup_tracing(); - - let mut package_database = MockRepoData::default(); - - // Add a package `foo` that depends on `bar` both set to version 1. - package_database.add_package(Package::build("rattler", "1").finish()); - package_database.add_package(Package::build("rattler", "2").finish()); - package_database.add_package(Package::build("rattler", "3").finish()); - - // Write the repodata to disk - let channel_dir = TempDir::new().unwrap(); - package_database - .write_repodata(channel_dir.path()) - .await - .unwrap(); - - let pixi = PixiControl::new().unwrap(); - - pixi.init() - .with_local_channel(channel_dir.path()) - .await - .unwrap(); - - // Add a package - pixi.add("rattler==1").await.unwrap(); - pixi.add("rattler==2") - .set_type(DependencyType::CondaDependency(SpecType::Host)) - .await - .unwrap(); - pixi.add("rattler==3") - .set_type(DependencyType::CondaDependency(SpecType::Build)) - .await - .unwrap(); - - let lock = pixi.lock_file().await.unwrap(); - assert!(lock.contains_match_spec( - consts::DEFAULT_ENVIRONMENT_NAME, - Platform::current(), - "rattler==3" - )); - assert!(!lock.contains_match_spec( - consts::DEFAULT_ENVIRONMENT_NAME, - Platform::current(), - "rattler==2" - )); - assert!(!lock.contains_match_spec( - consts::DEFAULT_ENVIRONMENT_NAME, - Platform::current(), - "rattler==1" - )); - - // remove the package, using matchspec - pixi.remove("rattler==1").await.unwrap(); - let lock = pixi.lock_file().await.unwrap(); - assert!(!lock.contains_match_spec( - consts::DEFAULT_ENVIRONMENT_NAME, - Platform::current(), - "rattler==1" - )); -} - -/// Test adding a package with a specific channel -#[tokio::test] -#[cfg_attr(not(feature = "online_tests"), ignore)] -async fn add_with_channel() { - setup_tracing(); - - let pixi = PixiControl::new().unwrap(); - - pixi.init().await.unwrap(); - - pixi.add("https://prefix.dev/conda-forge::_openmp_mutex") - .with_install(false) - .with_frozen(true) - .await - .unwrap(); - - pixi.project_channel_add() - .with_channel("https://prefix.dev/robostack-kilted") - .await - .unwrap(); - pixi.add("https://prefix.dev/robostack-kilted::ros2-distro-mutex") - .with_install(false) - .await - .unwrap(); - - let project = Workspace::from_path(pixi.manifest_path().as_path()).unwrap(); - let mut specs = project - .default_environment() - .combined_dependencies(Some(Platform::current())) - .into_specs(); - - let (name, spec) = specs.next().unwrap(); - assert_eq!(name, PackageName::try_from("_openmp_mutex").unwrap()); - assert_eq!( - spec.into_detailed().unwrap().channel.unwrap().as_str(), - "https://prefix.dev/conda-forge" - ); - - let (name, spec) = specs.next().unwrap(); - assert_eq!(name, PackageName::try_from("ros2-distro-mutex").unwrap()); - assert_eq!( - spec.into_detailed().unwrap().channel.unwrap().as_str(), - "https://prefix.dev/robostack-kilted" - ); -} - -/// Test that we get the union of all packages in the lockfile for the run, -/// build and host -#[tokio::test] -async fn add_functionality_union() { - setup_tracing(); - - let mut package_database = MockRepoData::default(); - - // Add a package `foo` that depends on `bar` both set to version 1. - package_database.add_package(Package::build("rattler", "1").finish()); - package_database.add_package(Package::build("libcomputer", "1.2").finish()); - package_database.add_package(Package::build("libidk", "3.1").finish()); - - // Write the repodata to disk - let channel_dir = TempDir::new().unwrap(); - package_database - .write_repodata(channel_dir.path()) - .await - .unwrap(); - - let pixi = PixiControl::new().unwrap(); - - pixi.init() - .with_local_channel(channel_dir.path()) - .await - .unwrap(); - - // Add a package - pixi.add("rattler").await.unwrap(); - pixi.add("libcomputer") - .set_type(DependencyType::CondaDependency(SpecType::Host)) - .await - .unwrap(); - pixi.add("libidk") - .set_type(DependencyType::CondaDependency(SpecType::Build)) - .await - .unwrap(); - - // Toml should contain the correct sections - // We test if the toml file that is saved is correct - // by checking if we get the correct values back in the manifest - // We know this works because we test the manifest in another test - // Where we check if the sections are put in the correct variables - let project = pixi.workspace().unwrap(); - - // Should contain all added dependencies - let dependencies = project - .default_environment() - .dependencies(SpecType::Run, Some(Platform::current())); - let (name, _) = dependencies.into_specs().next().unwrap(); - assert_eq!(name, PackageName::try_from("rattler").unwrap()); - let host_deps = project - .default_environment() - .dependencies(SpecType::Host, Some(Platform::current())); - let (name, _) = host_deps.into_specs().next().unwrap(); - assert_eq!(name, PackageName::try_from("libcomputer").unwrap()); - let build_deps = project - .default_environment() - .dependencies(SpecType::Build, Some(Platform::current())); - let (name, _) = build_deps.into_specs().next().unwrap(); - assert_eq!(name, PackageName::try_from("libidk").unwrap()); - - // Lock file should contain all packages as well - let lock = pixi.lock_file().await.unwrap(); - assert!(lock.contains_match_spec( - consts::DEFAULT_ENVIRONMENT_NAME, - Platform::current(), - "rattler==1" - )); - assert!(lock.contains_match_spec( - consts::DEFAULT_ENVIRONMENT_NAME, - Platform::current(), - "libcomputer==1.2" - )); - assert!(lock.contains_match_spec( - consts::DEFAULT_ENVIRONMENT_NAME, - Platform::current(), - "libidk==3.1" - )); -} - -/// Test adding a package for a specific OS -#[tokio::test] -async fn add_functionality_os() { - setup_tracing(); - - let mut package_database = MockRepoData::default(); - - // Add a package `foo` that depends on `bar` both set to version 1. - package_database.add_package( - Package::build("rattler", "1") - .with_subdir(Platform::LinuxS390X) - .finish(), - ); - - // Write the repodata to disk - let channel_dir = TempDir::new().unwrap(); - package_database - .write_repodata(channel_dir.path()) - .await - .unwrap(); - - let pixi = PixiControl::new().unwrap(); - - pixi.init() - .with_local_channel(channel_dir.path()) - .await - .unwrap(); - - // Add a package - pixi.add("rattler==1") - .set_platforms(&[Platform::LinuxS390X]) - .set_type(DependencyType::CondaDependency(SpecType::Host)) - .await - .unwrap(); - - let lock = pixi.lock_file().await.unwrap(); - assert!(lock.contains_match_spec( - consts::DEFAULT_ENVIRONMENT_NAME, - Platform::LinuxS390X, - "rattler==1" - )); -} - -/// Test the `pixi add --pypi` functionality (using local mocks) -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -async fn add_pypi_functionality() { - use crate::common::pypi_index::{Database as PyPIDatabase, PyPIPackage}; - - setup_tracing(); - - // Create local git fixtures for pypi git packages - let boltons_fixture = GitRepoFixture::new("pypi-boltons"); - let httpx_fixture = GitRepoFixture::new("pypi-httpx"); - let isort_fixture = GitRepoFixture::new("pypi-isort"); - - // Create local PyPI index with test packages - let pypi_index = PyPIDatabase::new() - .with(PyPIPackage::new("pipx", "1.7.1")) - .with( - PyPIPackage::new("pytest", "8.3.2").with_requires_dist(["mock; extra == \"dev\""]), // dev extra requires mock - ) - .with(PyPIPackage::new("mock", "5.0.0")) - .into_simple_index() - .unwrap(); - - // Create a separate flat index for direct wheel URL testing - let pytest_wheel = PyPIDatabase::new() - .with(PyPIPackage::new("pytest", "8.2.0")) - .into_flat_index() - .unwrap(); - let pytest_wheel_url = pytest_wheel - .url() - .join("pytest-8.2.0-py3-none-any.whl") - .unwrap(); - - // Create local conda channel with Python for multiple platforms - let mut package_db = MockRepoData::default(); - for platform in [Platform::current(), Platform::Linux64, Platform::Osx64] { - package_db.add_package( - Package::build("python", "3.12.0") - .with_subdir(platform) - .finish(), - ); - } - let channel = package_db.into_channel().await.unwrap(); - - let pixi = PixiControl::new().unwrap(); - - pixi.init() - .without_channels() - .with_local_channel(channel.url().to_file_path().unwrap()) - .with_platforms(vec![ - Platform::current(), - Platform::Linux64, - Platform::Osx64, - ]) - .await - .unwrap(); - - // Add pypi-options to the manifest - let manifest = pixi.manifest_contents().unwrap(); - let updated_manifest = format!( - "{}\n[pypi-options]\nindex-url = \"{}\"\n", - manifest, - pypi_index.index_url() - ); - pixi.update_manifest(&updated_manifest).unwrap(); - - // Add python - pixi.add("python~=3.12.0") - .set_type(DependencyType::CondaDependency(SpecType::Run)) - .await - .unwrap(); - - // Add a pypi package that is a wheel - // without installing should succeed - pixi.add("pipx==1.7.1") - .set_type(DependencyType::PypiDependency) - .await - .unwrap(); - - // Add a pypi package to a target with short hash (using local git fixture) - let boltons_short_commit = &boltons_fixture.first_commit()[..7]; - pixi.add(&format!( - "boltons @ git+{}@{}", - boltons_fixture.base_url, boltons_short_commit - )) - .set_type(DependencyType::PypiDependency) - .set_platforms(&[Platform::Osx64]) - .await - .unwrap(); - - // Add a pypi package to a target with extras - pixi.add("pytest[dev]==8.3.2") - .set_type(DependencyType::PypiDependency) - .set_platforms(&[Platform::Linux64]) - .await - .unwrap(); - - // Read project from file and check if the dev extras are added. - let project = Workspace::from_path(pixi.manifest_path().as_path()).unwrap(); - project - .default_environment() - .pypi_dependencies(None) - .into_specs() - .for_each(|(name, spec)| { - if name == PypiPackageName::from_str("pytest").unwrap() { - assert_eq!( - spec.extras(), - &[pep508_rs::ExtraName::from_str("dev").unwrap()] - ); - } - }); - - // Test all the added packages are in the lock file - let lock = pixi.lock_file().await.unwrap(); - assert!(lock.contains_pypi_package( - consts::DEFAULT_ENVIRONMENT_NAME, - Platform::current(), - "pipx" - )); - assert!(lock.contains_pep508_requirement( - consts::DEFAULT_ENVIRONMENT_NAME, - Platform::Osx64, - pep508_rs::Requirement::from_str("boltons").unwrap() - )); - assert!(lock.contains_pep508_requirement( - consts::DEFAULT_ENVIRONMENT_NAME, - Platform::Linux64, - pep508_rs::Requirement::from_str("pytest").unwrap(), - )); - // Test that the dev extras are added, mock is a test dependency of - // `pytest==8.3.2` - assert!(lock.contains_pep508_requirement( - consts::DEFAULT_ENVIRONMENT_NAME, - Platform::Linux64, - pep508_rs::Requirement::from_str("mock").unwrap(), - )); - - // Add a pypi package with a git url (using local fixture) - pixi.add(&format!("httpx @ git+{}", httpx_fixture.base_url)) - .set_type(DependencyType::PypiDependency) - .set_platforms(&[Platform::Linux64]) - .await - .unwrap(); - - // Add with specific commit (using local fixture) - let isort_commit = isort_fixture.first_commit(); - pixi.add(&format!( - "isort @ git+{}@{}", - isort_fixture.base_url, isort_commit - )) - .set_type(DependencyType::PypiDependency) - .set_platforms(&[Platform::Linux64]) - .await - .unwrap(); - - // Add pytest from direct wheel URL (using local wheel file) - pixi.add(&format!("pytest @ {pytest_wheel_url}")) - .set_type(DependencyType::PypiDependency) - .set_platforms(&[Platform::Linux64]) - .await - .unwrap(); - - let lock = pixi.lock_file().await.unwrap(); - assert!(lock.contains_pypi_package( - consts::DEFAULT_ENVIRONMENT_NAME, - Platform::Linux64, - "httpx" - )); - assert!(lock.contains_pypi_package( - consts::DEFAULT_ENVIRONMENT_NAME, - Platform::Linux64, - "isort" - )); - assert!(lock.contains_pypi_package( - consts::DEFAULT_ENVIRONMENT_NAME, - Platform::Linux64, - "pytest" - )); -} - -/// Test the `pixi add --pypi` functionality with extras (using local mocks) -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -async fn add_pypi_extra_functionality() { - use crate::common::pypi_index::{Database as PyPIDatabase, PyPIPackage}; - - setup_tracing(); - - // Create local PyPI index with black package (multiple versions, with cli extra) - let pypi_index = PyPIDatabase::new() - .with(PyPIPackage::new("black", "24.8.0")) - .with(PyPIPackage::new("black", "24.7.0")) - .with(PyPIPackage::new("click", "8.0.0")) // cli extra dependency - .into_simple_index() - .unwrap(); - - // Create local conda channel with Python - let mut package_db = MockRepoData::default(); - package_db.add_package( - Package::build("python", "3.12.0") - .with_subdir(Platform::current()) - .finish(), - ); - let channel = package_db.into_channel().await.unwrap(); - - let channel_url = channel.url(); - let index_url = pypi_index.index_url(); - let platform = Platform::current(); - - // Create manifest with local channel and pypi index - let pixi = PixiControl::from_manifest(&format!( - r#" -[workspace] -name = "test-pypi-extras" -channels = ["{channel_url}"] -platforms = ["{platform}"] - -[dependencies] -python = "==3.12.0" - -[pypi-options] -index-url = "{index_url}" -"# - )) - .unwrap(); - - pixi.add("black") - .set_type(DependencyType::PypiDependency) - .await - .unwrap(); - - // Add dep with extra - pixi.add("black[cli]") - .set_type(DependencyType::PypiDependency) - .await - .unwrap(); - - // Check if the extras are added - let project = Workspace::from_path(pixi.manifest_path().as_path()).unwrap(); - project - .default_environment() - .pypi_dependencies(None) - .into_specs() - .for_each(|(name, spec)| { - if name == PypiPackageName::from_str("black").unwrap() { - assert_eq!( - spec.extras(), - &[pep508_rs::ExtraName::from_str("cli").unwrap()] - ); - } - }); - - // Remove extras - pixi.add("black") - .set_type(DependencyType::PypiDependency) - .await - .unwrap(); - - // Check if the extras are removed - let project = Workspace::from_path(pixi.manifest_path().as_path()).unwrap(); - project - .default_environment() - .pypi_dependencies(None) - .into_specs() - .for_each(|(name, spec)| { - if name == PypiPackageName::from_str("black").unwrap() { - assert_eq!(spec.extras(), &[]); - } - }); - - // Add dep with extra and version - pixi.add("black[cli]==24.8.0") - .set_type(DependencyType::PypiDependency) - .await - .unwrap(); - - // Check if the extras added and the version is set - let project = Workspace::from_path(pixi.manifest_path().as_path()).unwrap(); - project - .default_environment() - .pypi_dependencies(None) - .into_specs() - .for_each(|(name, spec)| { - if name == PypiPackageName::from_str("black").unwrap() { - assert_eq!( - spec, - PixiPypiSpec::Version { - version: VersionOrStar::from_str("==24.8.0").unwrap(), - extras: vec![pep508_rs::ExtraName::from_str("cli").unwrap()], - index: None - } - ); - } - }); -} - -/// Test the sdist support for pypi packages -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -#[cfg_attr(not(feature = "slow_integration_tests"), ignore)] -async fn add_sdist_functionality() { - setup_tracing(); - - let pixi = PixiControl::new().unwrap(); - - pixi.init().await.unwrap(); - - // Add python - pixi.add("python") - .set_type(DependencyType::CondaDependency(SpecType::Run)) - .await - .unwrap(); - - // Add the sdist pypi package - pixi.add("sdist") - .set_type(DependencyType::PypiDependency) - .with_install(true) - .await - .unwrap(); -} - -#[tokio::test] -async fn add_unconstrained_dependency() { - setup_tracing(); - - // Create a channel with a single package - let mut package_database = MockRepoData::default(); - package_database.add_package(Package::build("foobar", "1").finish()); - package_database.add_package(Package::build("bar", "1").finish()); - let local_channel = package_database.into_channel().await.unwrap(); - - // Initialize a new pixi project using the above channel - let pixi = PixiControl::new().unwrap(); - pixi.init().with_channel(local_channel.url()).await.unwrap(); - - // Add the `packages` to the project - pixi.add("foobar").await.unwrap(); - pixi.add("bar").with_feature("unreferenced").await.unwrap(); - - let project = pixi.workspace().unwrap(); - - // Get the specs for the `foobar` package - let foo_spec = project - .workspace - .value - .default_feature() - .combined_dependencies(None) - .unwrap_or_default() - .get_single("foobar") - .unwrap() - .unwrap() - .clone() - .to_toml_value() - .to_string(); - - // Get the specs for the `bar` package - let bar_spec = project - .workspace - .value - .feature("unreferenced") - .expect("feature 'unreferenced' is missing") - .combined_dependencies(None) - .unwrap_or_default() - .get_single("bar") - .unwrap() - .unwrap() - .clone() - .to_toml_value() - .to_string(); - - insta::assert_snapshot!(format!("foobar = {foo_spec}\nbar = {bar_spec}"), @r###" - foobar = ">=1,<2" - bar = "*" - "###); -} - -#[tokio::test] -async fn pinning_dependency() { - setup_tracing(); - - // Create a channel with a single package - let mut package_database = MockRepoData::default(); - package_database.add_package(Package::build("foobar", "1").finish()); - package_database.add_package(Package::build("python", "3.13").finish()); - - let local_channel = package_database.into_channel().await.unwrap(); - - // Initialize a new pixi project using the above channel - let pixi = PixiControl::new().unwrap(); - pixi.init().with_channel(local_channel.url()).await.unwrap(); - - // Add the `packages` to the project - pixi.add("foobar").await.unwrap(); - pixi.add("python").await.unwrap(); - - let project = pixi.workspace().unwrap(); - - // Get the specs for the `python` package - let python_spec = project - .workspace - .value - .default_feature() - .dependencies(SpecType::Run, None) - .unwrap_or_default() - .get_single("python") - .unwrap() - .unwrap() - .clone() - .to_toml_value() - .to_string(); - // Testing to see if edge cases are handled correctly - // Python shouldn't be automatically pinned to a major version. - assert_eq!(python_spec, r#"">=3.13,<3.14""#); - - // Get the specs for the `foobar` package - let foobar_spec = project - .workspace - .value - .default_feature() - .dependencies(SpecType::Run, None) - .unwrap_or_default() - .get_single("foobar") - .unwrap() - .unwrap() - .clone() - .to_toml_value() - .to_string(); - assert_eq!(foobar_spec, r#"">=1,<2""#); - - // Add the `python` package with a specific version - pixi.add("python==3.13").await.unwrap(); - let project = pixi.workspace().unwrap(); - let python_spec = project - .workspace - .value - .default_feature() - .dependencies(SpecType::Run, None) - .unwrap_or_default() - .get_single("python") - .unwrap() - .unwrap() - .clone() - .to_toml_value() - .to_string(); - assert_eq!(python_spec, r#""==3.13""#); -} - -#[tokio::test] -async fn add_dependency_pinning_strategy() { - setup_tracing(); - - // Create a channel with two packages - let mut package_database = MockRepoData::default(); - package_database.add_package(Package::build("foo", "1").finish()); - package_database.add_package(Package::build("bar", "1").finish()); - package_database.add_package(Package::build("python", "3.13").finish()); - - let local_channel = package_database.into_channel().await.unwrap(); - - // Initialize a new pixi project using the above channel - let pixi = PixiControl::new().unwrap(); - pixi.init().with_channel(local_channel.url()).await.unwrap(); - - // Add the `packages` to the project - pixi.add_multiple(vec!["foo", "python", "bar"]) - .await - .unwrap(); - - let project = pixi.workspace().unwrap(); - - // Get the specs for the `foo` package - let foo_spec = project - .workspace - .value - .default_feature() - .dependencies(SpecType::Run, None) - .unwrap_or_default() - .get_single("foo") - .unwrap() - .unwrap() - .clone() - .to_toml_value() - .to_string(); - assert_eq!(foo_spec, r#"">=1,<2""#); - - // Get the specs for the `python` package - let python_spec = project - .workspace - .value - .default_feature() - .dependencies(SpecType::Run, None) - .unwrap_or_default() - .get_single("python") - .unwrap() - .unwrap() - .clone() - .to_toml_value() - .to_string(); - // Testing to see if edge cases are handled correctly - // Python shouldn't be automatically pinned to a major version. - assert_eq!(python_spec, r#"">=3.13,<3.14""#); - - // Get the specs for the `bar` package - let bar_spec = project - .workspace - .value - .default_feature() - .dependencies(SpecType::Run, None) - .unwrap_or_default() - .get_single("bar") - .unwrap() - .unwrap() - .clone() - .to_toml_value() - .to_string(); - // Testing to make sure bugfix did not regressed - // Package should be automatically pinned to a major version - assert_eq!(bar_spec, r#"">=1,<2""#); -} - -/// Test adding a git dependency with a specific branch (using local fixture) -#[tokio::test] -async fn add_git_deps() { - setup_tracing(); - - // Create local git fixture with passthrough backend - let fixture = GitRepoFixture::new("conda-build-package"); - let backend_override = BackendOverride::from_memory(PassthroughBackend::instantiator()); - - let pixi = PixiControl::from_manifest( - r#" -[workspace] -name = "test-channel-change" -channels = ["https://prefix.dev/conda-forge"] -platforms = ["win-64"] -preview = ['pixi-build'] -"#, - ) - .unwrap() - .with_backend_override(backend_override); - - // Add a package using local git fixture URL - pixi.add("boost-check") - .with_git_url(fixture.base_url.clone()) - .with_git_rev(GitRev::new().with_branch("main".to_string())) - .with_git_subdir("boost-check".to_string()) - .await - .unwrap(); - - let lock = pixi.lock_file().await.unwrap(); - let git_package = lock - .default_environment() - .unwrap() - .packages(Platform::Win64) - .unwrap() - .find(|p| p.as_conda().unwrap().location().as_str().contains("git+")); - - let location = git_package - .unwrap() - .as_conda() - .unwrap() - .location() - .to_string(); - - insta::with_settings!({filters => vec![ - (r"file://[^?#]+", "file://[TEMP_PATH]"), - (r"#[a-f0-9]+", "#[COMMIT]"), - ]}, { - insta::assert_snapshot!(location, @"git+file://[TEMP_PATH]?subdirectory=boost-check&branch=main#[COMMIT]"); - }); -} - -/// Test adding git dependencies with credentials -/// This tests is skipped on windows because it spawns a credential helper -/// during the CI run -#[cfg(not(windows))] -#[tokio::test] -#[cfg_attr(not(feature = "online_tests"), ignore)] -async fn add_git_deps_with_creds() { - setup_tracing(); - - let pixi = PixiControl::from_manifest( - r#" -[workspace] -name = "test-channel-change" -channels = ["https://prefix.dev/conda-forge"] -platforms = ["linux-64"] -preview = ['pixi-build'] -"#, - ) - .unwrap(); - - // Add a package - // we want to make sure that the credentials are not exposed in the lock file - pixi.add("boost-check") - .with_git_url( - Url::parse("https://user:token123@github.com/wolfv/pixi-build-examples.git").unwrap(), - ) - .with_git_rev(GitRev::new().with_branch("main".to_string())) - .with_git_subdir("boost-check".to_string()) - .await - .unwrap(); - - let lock = pixi.lock_file().await.unwrap(); - let git_package = lock - .default_environment() - .unwrap() - .packages(Platform::Linux64) - .unwrap() - .find(|p| p.as_conda().unwrap().location().as_str().contains("git+")); - - insta::with_settings!({filters => vec![ - (r"#([a-f0-9]+)", "#[FULL_COMMIT]"), - ]}, { - insta::assert_snapshot!(git_package.unwrap().as_conda().unwrap().location()); - - }); - - // Check the manifest itself - insta::assert_snapshot!( - pixi.workspace() - .unwrap() - .modify() - .unwrap() - .manifest() - .document - .to_string() - ); -} - -/// Test adding a git dependency with a specific commit (using local fixture) -#[tokio::test] -async fn add_git_with_specific_commit() { - setup_tracing(); - - // Create local git fixture with passthrough backend - let fixture = GitRepoFixture::new("conda-build-package"); - let backend_override = BackendOverride::from_memory(PassthroughBackend::instantiator()); - - let pixi = PixiControl::from_manifest( - r#" -[workspace] -name = "test-channel-change" -channels = ["https://prefix.dev/conda-forge"] -platforms = ["linux-64"] -preview = ['pixi-build']"#, - ) - .unwrap() - .with_backend_override(backend_override); - - // Add a package using the first commit from our fixture - let first_commit = fixture.first_commit().to_string(); - let short_commit = &first_commit[..7]; // Use short hash like the original test - - pixi.add("boost-check") - .with_git_url(fixture.base_url.clone()) - .with_git_rev(GitRev::new().with_rev(short_commit.to_string())) - .with_git_subdir("boost-check".to_string()) - .await - .unwrap(); - - // Check the lock file - let lock = pixi.lock_file().await.unwrap(); - let git_package = lock - .default_environment() - .unwrap() - .packages(Platform::Linux64) - .unwrap() - .find(|p| p.as_conda().unwrap().location().as_str().contains("git+")); - - let location = git_package - .unwrap() - .as_conda() - .unwrap() - .location() - .to_string(); - - insta::with_settings!({filters => vec![ - (r"file://[^?#]+", "file://[TEMP_PATH]"), - (r"rev=[a-f0-9]+", "rev=[SHORT_COMMIT]"), - (r"#[a-f0-9]+", "#[FULL_COMMIT]"), - ]}, { - insta::assert_snapshot!(location, @"git+file://[TEMP_PATH]?subdirectory=boost-check&rev=[SHORT_COMMIT]#[FULL_COMMIT]"); - }); -} - -/// Test adding a git dependency with a specific tag (using local fixture) -#[tokio::test] -async fn add_git_with_tag() { - setup_tracing(); - - // Create local git fixture with passthrough backend - // The fixture creates a tag "v0.1.0" for the second commit - let fixture = GitRepoFixture::new("conda-build-package"); - let backend_override = BackendOverride::from_memory(PassthroughBackend::instantiator()); - - let pixi = PixiControl::from_manifest( - r#" -[workspace] -name = "test-channel-change" -channels = ["https://prefix.dev/conda-forge"] -platforms = ["win-64"] -preview = ['pixi-build']"#, - ) - .unwrap() - .with_backend_override(backend_override); - - // Add a package using the tag from our fixture - let tag_commit = fixture.tag_commit("v0.1.0").to_string(); - - pixi.add("boost-check") - .with_git_url(fixture.base_url.clone()) - .with_git_rev(GitRev::new().with_tag("v0.1.0".to_string())) - .with_git_subdir("boost-check".to_string()) - .await - .unwrap(); - - // Check the lock file - let lock = pixi.lock_file().await.unwrap(); - let git_package = lock - .default_environment() - .unwrap() - .packages(Platform::Win64) - .unwrap() - .find(|p| p.as_conda().unwrap().location().as_str().contains("git+")); - - let location = git_package - .unwrap() - .as_conda() - .unwrap() - .location() - .to_string(); - - insta::with_settings!({filters => vec![ - (r"file://[^?#]+", "file://[TEMP_PATH]"), - (r"#[a-f0-9]+", "#[COMMIT]"), - ]}, { - insta::assert_snapshot!(location, @"git+file://[TEMP_PATH]?subdirectory=boost-check&tag=v0.1.0#[COMMIT]"); - }); - - // Verify the commit hash matches the tag's commit - assert!( - location.ends_with(&format!("#{tag_commit}")), - "Expected tag to resolve to commit {tag_commit}, got {location}" - ); -} - -/// Test adding a git dependency using ssh url -#[tokio::test] -async fn add_plain_ssh_url() { - setup_tracing(); - - let pixi = PixiControl::from_manifest( - r#" -[workspace] -name = "test-channel-change" -channels = ["https://prefix.dev/conda-forge"] -platforms = ["linux-64"] -preview = ['pixi-build']"#, - ) - .unwrap(); - - // Add a package - pixi.add("boost-check") - .with_git_url(Url::parse("git+ssh://git@github.com/wolfv/pixi-build-examples.git").unwrap()) - .with_install(false) - .with_frozen(true) - .await - .unwrap(); - - // Check the manifest itself - insta::assert_snapshot!( - pixi.workspace() - .unwrap() - .workspace - .provenance - .read() - .unwrap() - .into_inner() - ); -} - -/// Test adding a git dependency using ssh url -#[tokio::test] -#[cfg_attr(not(feature = "online_tests"), ignore)] -async fn add_pypi_git() { - setup_tracing(); - - let pixi = PixiControl::from_manifest( - format!( - r#" -[workspace] -name = "test-channel-change" -channels = ["https://prefix.dev/conda-forge"] -platforms = ["{platform}"] - -"#, - platform = Platform::current() - ) - .as_str(), - ) - .unwrap(); - - // Add python - pixi.add("python>=3.13.2,<3.14").await.unwrap(); - - // Add a package - pixi.add("boltons") - .set_pypi(true) - .with_git_url(Url::parse("https://github.com/mahmoud/boltons.git").unwrap()) - .await - .unwrap(); - - // Check the manifest itself - insta::with_settings!({filters => vec![ - (r"#([a-f0-9]+)", "#[FULL_COMMIT]"), - (r"platforms = \[.*\]", "platforms = [\"\"]"), - ]}, { - insta::assert_snapshot!(pixi.workspace().unwrap().workspace.provenance.read().unwrap().into_inner()); - }); - - let lock_file = pixi.lock_file().await.unwrap(); - - let (boltons, _) = lock_file - .default_environment() - .unwrap() - .pypi_packages(Platform::current()) - .unwrap() - .find(|(p, _)| p.name.to_string() == "boltons") - .unwrap(); - - insta::with_settings!( {filters => vec![ - (r"#([a-f0-9]+)", "#[FULL_COMMIT]"), - ]}, { - insta::assert_snapshot!(boltons.location); - }); -} - -#[tokio::test] -async fn add_git_dependency_without_preview_feature_fails() { - setup_tracing(); - - let pixi = PixiControl::from_manifest( - r#" -[workspace] -name = "test-git-no-preview" -channels = ["https://prefix.dev/conda-forge"] -platforms = ["linux-64"] -"#, - ) - .unwrap(); - - let result = pixi - .add("boost-check") - .with_git_url(Url::parse("https://github.com/wolfv/pixi-build-examples.git").unwrap()) - .with_git_subdir("boost-check".to_string()) - .await; - - assert!(result.is_err()); - let error = result.unwrap_err(); - - // Use insta to snapshot test the full error message format including help text - insta::with_settings!({ - filters => vec![ - // Filter out the dynamic manifest path to make the snapshot stable - (r"manifest \([^)]+\)", "manifest ()"), - ] - }, { - insta::assert_debug_snapshot!("git_dependency_without_preview_error", error); - }); -} - -#[tokio::test] -async fn add_git_dependency_with_preview_feature_succeeds() { - setup_tracing(); - - let pixi = PixiControl::from_manifest( - r#" -[workspace] -name = "test-git-with-preview" -channels = ["https://prefix.dev/conda-forge"] -platforms = ["linux-64"] -preview = ["pixi-build"] -"#, - ) - .unwrap(); - - let result = pixi - .add("boost-check") - .with_git_url(Url::parse("https://github.com/wolfv/pixi-build-examples.git").unwrap()) - .with_git_subdir("boost-check".to_string()) - .with_install(false) - .with_frozen(true) - .await; - - assert!(result.is_ok()); - - let workspace = pixi.workspace().unwrap(); - let deps = workspace - .default_environment() - .combined_dependencies(Some(Platform::Linux64)); - - let (name, spec) = deps - .into_specs() - .find(|(name, _)| name.as_normalized() == "boost-check") - .unwrap(); - assert_eq!(name.as_normalized(), "boost-check"); - assert!(spec.is_source()); -} - -#[tokio::test] -async fn add_dependency_dont_create_project() { - setup_tracing(); - - // Create a channel with two packages - let mut package_database = MockRepoData::default(); - package_database.add_package(Package::build("foo", "1").finish()); - package_database.add_package(Package::build("bar", "1").finish()); - package_database.add_package(Package::build("python", "3.13").finish()); - - let local_channel = package_database.into_channel().await.unwrap(); - - let local_channel_str = format!("{}", local_channel.url()); - - // Initialize a new pixi project using the above channel - let pixi = PixiControl::from_manifest(&format!( - r#" -[workspace] -name = "some-workspace" -platforms = [] -channels = ['{local_channel_str}'] -preview = ['pixi-build'] -"# - )) - .unwrap(); - - // Add the `packages` to the project - pixi.add("foo").await.unwrap(); - - let workspace = pixi.workspace().unwrap(); - - // filter out local channels from the insta - insta::with_settings!({filters => vec![ - (local_channel_str.as_str(), "file:///"), - ]}, { - insta::assert_snapshot!(workspace.workspace.provenance.read().unwrap().into_inner()); - }); -} +use std::str::FromStr; + +use pixi_cli::cli_config::GitRev; +use pixi_consts::consts; +use pixi_core::{DependencyType, Workspace}; +use pixi_manifest::{FeaturesExt, SpecType}; +use pixi_pypi_spec::{PixiPypiSpec, PypiPackageName, VersionOrStar}; +use rattler_conda_types::{PackageName, Platform}; +use tempfile::TempDir; +use url::Url; + +use pixi_build_backend_passthrough::PassthroughBackend; +use pixi_build_frontend::BackendOverride; + +use crate::common::{ + LockFileExt, PixiControl, + builders::{HasDependencyConfig, HasLockFileUpdateConfig, HasNoInstallConfig}, +}; +use crate::setup_tracing; +use pixi_test_utils::{GitRepoFixture, MockRepoData, Package}; + +/// Test add functionality for different types of packages. +/// Run, dev, build +#[tokio::test] +async fn add_functionality() { + setup_tracing(); + + let mut package_database = MockRepoData::default(); + + // Add a package `foo` that depends on `bar` both set to version 1. + package_database.add_package(Package::build("rattler", "1").finish()); + package_database.add_package(Package::build("rattler", "2").finish()); + package_database.add_package(Package::build("rattler", "3").finish()); + + // Write the repodata to disk + let channel_dir = TempDir::new().unwrap(); + package_database + .write_repodata(channel_dir.path()) + .await + .unwrap(); + + let pixi = PixiControl::new().unwrap(); + + pixi.init() + .with_local_channel(channel_dir.path()) + .await + .unwrap(); + + // Add a package + pixi.add("rattler==1").await.unwrap(); + pixi.add("rattler==2") + .set_type(DependencyType::CondaDependency(SpecType::Host)) + .await + .unwrap(); + pixi.add("rattler==3") + .set_type(DependencyType::CondaDependency(SpecType::Build)) + .await + .unwrap(); + + let lock = pixi.lock_file().await.unwrap(); + assert!(lock.contains_match_spec( + consts::DEFAULT_ENVIRONMENT_NAME, + Platform::current(), + "rattler==3" + )); + assert!(!lock.contains_match_spec( + consts::DEFAULT_ENVIRONMENT_NAME, + Platform::current(), + "rattler==2" + )); + assert!(!lock.contains_match_spec( + consts::DEFAULT_ENVIRONMENT_NAME, + Platform::current(), + "rattler==1" + )); + + // remove the package, using matchspec + pixi.remove("rattler==1").await.unwrap(); + let lock = pixi.lock_file().await.unwrap(); + assert!(!lock.contains_match_spec( + consts::DEFAULT_ENVIRONMENT_NAME, + Platform::current(), + "rattler==1" + )); +} + +/// Test adding a package with a specific channel +#[tokio::test] +#[cfg_attr(not(feature = "online_tests"), ignore)] +async fn add_with_channel() { + setup_tracing(); + + let pixi = PixiControl::new().unwrap(); + + pixi.init().await.unwrap(); + + pixi.add("https://prefix.dev/conda-forge::_openmp_mutex") + .with_install(false) + .with_frozen(true) + .await + .unwrap(); + + pixi.project_channel_add() + .with_channel("https://prefix.dev/robostack-kilted") + .await + .unwrap(); + pixi.add("https://prefix.dev/robostack-kilted::ros2-distro-mutex") + .with_install(false) + .await + .unwrap(); + + let project = Workspace::from_path(pixi.manifest_path().as_path()).unwrap(); + let mut specs = project + .default_environment() + .combined_dependencies(Some(Platform::current())) + .into_specs(); + + let (name, spec) = specs.next().unwrap(); + assert_eq!(name, PackageName::try_from("_openmp_mutex").unwrap()); + assert_eq!( + spec.into_detailed().unwrap().channel.unwrap().as_str(), + "https://prefix.dev/conda-forge" + ); + + let (name, spec) = specs.next().unwrap(); + assert_eq!(name, PackageName::try_from("ros2-distro-mutex").unwrap()); + assert_eq!( + spec.into_detailed().unwrap().channel.unwrap().as_str(), + "https://prefix.dev/robostack-kilted" + ); +} + +/// Test that we get the union of all packages in the lockfile for the run, +/// build and host +#[tokio::test] +async fn add_functionality_union() { + setup_tracing(); + + let mut package_database = MockRepoData::default(); + + // Add a package `foo` that depends on `bar` both set to version 1. + package_database.add_package(Package::build("rattler", "1").finish()); + package_database.add_package(Package::build("libcomputer", "1.2").finish()); + package_database.add_package(Package::build("libidk", "3.1").finish()); + + // Write the repodata to disk + let channel_dir = TempDir::new().unwrap(); + package_database + .write_repodata(channel_dir.path()) + .await + .unwrap(); + + let pixi = PixiControl::new().unwrap(); + + pixi.init() + .with_local_channel(channel_dir.path()) + .await + .unwrap(); + + // Add a package + pixi.add("rattler").await.unwrap(); + pixi.add("libcomputer") + .set_type(DependencyType::CondaDependency(SpecType::Host)) + .await + .unwrap(); + pixi.add("libidk") + .set_type(DependencyType::CondaDependency(SpecType::Build)) + .await + .unwrap(); + + // Toml should contain the correct sections + // We test if the toml file that is saved is correct + // by checking if we get the correct values back in the manifest + // We know this works because we test the manifest in another test + // Where we check if the sections are put in the correct variables + let project = pixi.workspace().unwrap(); + + // Should contain all added dependencies + let dependencies = project + .default_environment() + .dependencies(SpecType::Run, Some(Platform::current())); + let (name, _) = dependencies.into_specs().next().unwrap(); + assert_eq!(name, PackageName::try_from("rattler").unwrap()); + let host_deps = project + .default_environment() + .dependencies(SpecType::Host, Some(Platform::current())); + let (name, _) = host_deps.into_specs().next().unwrap(); + assert_eq!(name, PackageName::try_from("libcomputer").unwrap()); + let build_deps = project + .default_environment() + .dependencies(SpecType::Build, Some(Platform::current())); + let (name, _) = build_deps.into_specs().next().unwrap(); + assert_eq!(name, PackageName::try_from("libidk").unwrap()); + + // Lock file should contain all packages as well + let lock = pixi.lock_file().await.unwrap(); + assert!(lock.contains_match_spec( + consts::DEFAULT_ENVIRONMENT_NAME, + Platform::current(), + "rattler==1" + )); + assert!(lock.contains_match_spec( + consts::DEFAULT_ENVIRONMENT_NAME, + Platform::current(), + "libcomputer==1.2" + )); + assert!(lock.contains_match_spec( + consts::DEFAULT_ENVIRONMENT_NAME, + Platform::current(), + "libidk==3.1" + )); +} + +/// Test adding a package for a specific OS +#[tokio::test] +async fn add_functionality_os() { + setup_tracing(); + + let mut package_database = MockRepoData::default(); + + // Add a package `foo` that depends on `bar` both set to version 1. + package_database.add_package( + Package::build("rattler", "1") + .with_subdir(Platform::LinuxS390X) + .finish(), + ); + + // Write the repodata to disk + let channel_dir = TempDir::new().unwrap(); + package_database + .write_repodata(channel_dir.path()) + .await + .unwrap(); + + let pixi = PixiControl::new().unwrap(); + + pixi.init() + .with_local_channel(channel_dir.path()) + .await + .unwrap(); + + // Add a package + pixi.add("rattler==1") + .set_platforms(&[Platform::LinuxS390X]) + .set_type(DependencyType::CondaDependency(SpecType::Host)) + .await + .unwrap(); + + let lock = pixi.lock_file().await.unwrap(); + assert!(lock.contains_match_spec( + consts::DEFAULT_ENVIRONMENT_NAME, + Platform::LinuxS390X, + "rattler==1" + )); +} + +/// Test the `pixi add --pypi` functionality (using local mocks) +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn add_pypi_functionality() { + use crate::common::pypi_index::{Database as PyPIDatabase, PyPIPackage}; + + setup_tracing(); + + // Create local git fixtures for pypi git packages + let boltons_fixture = GitRepoFixture::new("pypi-boltons"); + let httpx_fixture = GitRepoFixture::new("pypi-httpx"); + let isort_fixture = GitRepoFixture::new("pypi-isort"); + + // Create local PyPI index with test packages + let pypi_index = PyPIDatabase::new() + .with(PyPIPackage::new("pipx", "1.7.1")) + .with( + PyPIPackage::new("pytest", "8.3.2").with_requires_dist(["mock; extra == \"dev\""]), // dev extra requires mock + ) + .with(PyPIPackage::new("mock", "5.0.0")) + .into_simple_index() + .unwrap(); + + // Create a separate flat index for direct wheel URL testing + let pytest_wheel = PyPIDatabase::new() + .with(PyPIPackage::new("pytest", "8.2.0")) + .into_flat_index() + .unwrap(); + let pytest_wheel_url = pytest_wheel + .url() + .join("pytest-8.2.0-py3-none-any.whl") + .unwrap(); + + // Create local conda channel with Python for multiple platforms + let mut package_db = MockRepoData::default(); + for platform in [Platform::current(), Platform::Linux64, Platform::Osx64] { + package_db.add_package( + Package::build("python", "3.12.0") + .with_subdir(platform) + .finish(), + ); + } + let channel = package_db.into_channel().await.unwrap(); + + let pixi = PixiControl::new().unwrap(); + + pixi.init() + .without_channels() + .with_local_channel(channel.url().to_file_path().unwrap()) + .with_platforms(vec![ + Platform::current(), + Platform::Linux64, + Platform::Osx64, + ]) + .await + .unwrap(); + + // Add pypi-options to the manifest + let manifest = pixi.manifest_contents().unwrap(); + let updated_manifest = format!( + "{}\n[pypi-options]\nindex-url = \"{}\"\n", + manifest, + pypi_index.index_url() + ); + pixi.update_manifest(&updated_manifest).unwrap(); + + // Add python + pixi.add("python~=3.12.0") + .set_type(DependencyType::CondaDependency(SpecType::Run)) + .await + .unwrap(); + + // Add a pypi package that is a wheel + // without installing should succeed + pixi.add("pipx==1.7.1") + .set_type(DependencyType::PypiDependency) + .await + .unwrap(); + + // Add a pypi package to a target with short hash (using local git fixture) + let boltons_short_commit = &boltons_fixture.first_commit()[..7]; + pixi.add(&format!( + "boltons @ git+{}@{}", + boltons_fixture.base_url, boltons_short_commit + )) + .set_type(DependencyType::PypiDependency) + .set_platforms(&[Platform::Osx64]) + .await + .unwrap(); + + // Add a pypi package to a target with extras + pixi.add("pytest[dev]==8.3.2") + .set_type(DependencyType::PypiDependency) + .set_platforms(&[Platform::Linux64]) + .await + .unwrap(); + + // Read project from file and check if the dev extras are added. + let project = Workspace::from_path(pixi.manifest_path().as_path()).unwrap(); + project + .default_environment() + .pypi_dependencies(None) + .into_specs() + .for_each(|(name, spec)| { + if name == PypiPackageName::from_str("pytest").unwrap() { + assert_eq!( + spec.extras(), + &[pep508_rs::ExtraName::from_str("dev").unwrap()] + ); + } + }); + + // Test all the added packages are in the lock file + let lock = pixi.lock_file().await.unwrap(); + assert!(lock.contains_pypi_package( + consts::DEFAULT_ENVIRONMENT_NAME, + Platform::current(), + "pipx" + )); + assert!(lock.contains_pep508_requirement( + consts::DEFAULT_ENVIRONMENT_NAME, + Platform::Osx64, + pep508_rs::Requirement::from_str("boltons").unwrap() + )); + assert!(lock.contains_pep508_requirement( + consts::DEFAULT_ENVIRONMENT_NAME, + Platform::Linux64, + pep508_rs::Requirement::from_str("pytest").unwrap(), + )); + // Test that the dev extras are added, mock is a test dependency of + // `pytest==8.3.2` + assert!(lock.contains_pep508_requirement( + consts::DEFAULT_ENVIRONMENT_NAME, + Platform::Linux64, + pep508_rs::Requirement::from_str("mock").unwrap(), + )); + + // Add a pypi package with a git url (using local fixture) + pixi.add(&format!("httpx @ git+{}", httpx_fixture.base_url)) + .set_type(DependencyType::PypiDependency) + .set_platforms(&[Platform::Linux64]) + .await + .unwrap(); + + // Add with specific commit (using local fixture) + let isort_commit = isort_fixture.first_commit(); + pixi.add(&format!( + "isort @ git+{}@{}", + isort_fixture.base_url, isort_commit + )) + .set_type(DependencyType::PypiDependency) + .set_platforms(&[Platform::Linux64]) + .await + .unwrap(); + + // Add pytest from direct wheel URL (using local wheel file) + pixi.add(&format!("pytest @ {pytest_wheel_url}")) + .set_type(DependencyType::PypiDependency) + .set_platforms(&[Platform::Linux64]) + .await + .unwrap(); + + let lock = pixi.lock_file().await.unwrap(); + assert!(lock.contains_pypi_package( + consts::DEFAULT_ENVIRONMENT_NAME, + Platform::Linux64, + "httpx" + )); + assert!(lock.contains_pypi_package( + consts::DEFAULT_ENVIRONMENT_NAME, + Platform::Linux64, + "isort" + )); + assert!(lock.contains_pypi_package( + consts::DEFAULT_ENVIRONMENT_NAME, + Platform::Linux64, + "pytest" + )); +} + +/// Test the `pixi add --pypi` functionality with extras (using local mocks) +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn add_pypi_extra_functionality() { + use crate::common::pypi_index::{Database as PyPIDatabase, PyPIPackage}; + + setup_tracing(); + + // Create local PyPI index with black package (multiple versions, with cli extra) + let pypi_index = PyPIDatabase::new() + .with(PyPIPackage::new("black", "24.8.0")) + .with(PyPIPackage::new("black", "24.7.0")) + .with(PyPIPackage::new("click", "8.0.0")) // cli extra dependency + .into_simple_index() + .unwrap(); + + // Create local conda channel with Python + let mut package_db = MockRepoData::default(); + package_db.add_package( + Package::build("python", "3.12.0") + .with_subdir(Platform::current()) + .finish(), + ); + let channel = package_db.into_channel().await.unwrap(); + + let channel_url = channel.url(); + let index_url = pypi_index.index_url(); + let platform = Platform::current(); + + // Create manifest with local channel and pypi index + let pixi = PixiControl::from_manifest(&format!( + r#" +[workspace] +name = "test-pypi-extras" +channels = ["{channel_url}"] +platforms = ["{platform}"] + +[dependencies] +python = "==3.12.0" + +[pypi-options] +index-url = "{index_url}" +"# + )) + .unwrap(); + + pixi.add("black") + .set_type(DependencyType::PypiDependency) + .await + .unwrap(); + + // Add dep with extra + pixi.add("black[cli]") + .set_type(DependencyType::PypiDependency) + .await + .unwrap(); + + // Check if the extras are added + let project = Workspace::from_path(pixi.manifest_path().as_path()).unwrap(); + project + .default_environment() + .pypi_dependencies(None) + .into_specs() + .for_each(|(name, spec)| { + if name == PypiPackageName::from_str("black").unwrap() { + assert_eq!( + spec.extras(), + &[pep508_rs::ExtraName::from_str("cli").unwrap()] + ); + } + }); + + // Remove extras + pixi.add("black") + .set_type(DependencyType::PypiDependency) + .await + .unwrap(); + + // Check if the extras are removed + let project = Workspace::from_path(pixi.manifest_path().as_path()).unwrap(); + project + .default_environment() + .pypi_dependencies(None) + .into_specs() + .for_each(|(name, spec)| { + if name == PypiPackageName::from_str("black").unwrap() { + assert_eq!(spec.extras(), &[]); + } + }); + + // Add dep with extra and version + pixi.add("black[cli]==24.8.0") + .set_type(DependencyType::PypiDependency) + .await + .unwrap(); + + // Check if the extras added and the version is set + let project = Workspace::from_path(pixi.manifest_path().as_path()).unwrap(); + project + .default_environment() + .pypi_dependencies(None) + .into_specs() + .for_each(|(name, spec)| { + if name == PypiPackageName::from_str("black").unwrap() { + assert_eq!( + spec, + PixiPypiSpec::Version { + version: VersionOrStar::from_str("==24.8.0").unwrap(), + extras: vec![pep508_rs::ExtraName::from_str("cli").unwrap()], + index: None + } + ); + } + }); +} + +/// Test the sdist support for pypi packages +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[cfg_attr(not(feature = "slow_integration_tests"), ignore)] +async fn add_sdist_functionality() { + setup_tracing(); + + let pixi = PixiControl::new().unwrap(); + + pixi.init().await.unwrap(); + + // Add python + pixi.add("python") + .set_type(DependencyType::CondaDependency(SpecType::Run)) + .await + .unwrap(); + + // Add the sdist pypi package + pixi.add("sdist") + .set_type(DependencyType::PypiDependency) + .with_install(true) + .await + .unwrap(); +} + +#[tokio::test] +async fn add_unconstrained_dependency() { + setup_tracing(); + + // Create a channel with a single package + let mut package_database = MockRepoData::default(); + package_database.add_package(Package::build("foobar", "1").finish()); + package_database.add_package(Package::build("bar", "1").finish()); + let local_channel = package_database.into_channel().await.unwrap(); + + // Initialize a new pixi project using the above channel + let pixi = PixiControl::new().unwrap(); + pixi.init().with_channel(local_channel.url()).await.unwrap(); + + // Add the `packages` to the project + pixi.add("foobar").await.unwrap(); + pixi.add("bar").with_feature("unreferenced").await.unwrap(); + + let project = pixi.workspace().unwrap(); + + // Get the specs for the `foobar` package + let foo_spec = project + .workspace + .value + .default_feature() + .combined_dependencies(None) + .unwrap_or_default() + .get_single("foobar") + .unwrap() + .unwrap() + .clone() + .to_toml_value() + .to_string(); + + // Get the specs for the `bar` package + let bar_spec = project + .workspace + .value + .feature("unreferenced") + .expect("feature 'unreferenced' is missing") + .combined_dependencies(None) + .unwrap_or_default() + .get_single("bar") + .unwrap() + .unwrap() + .clone() + .to_toml_value() + .to_string(); + + insta::assert_snapshot!(format!("foobar = {foo_spec}\nbar = {bar_spec}"), @r###" + foobar = ">=1,<2" + bar = "*" + "###); +} + +#[tokio::test] +async fn pinning_dependency() { + setup_tracing(); + + // Create a channel with a single package + let mut package_database = MockRepoData::default(); + package_database.add_package(Package::build("foobar", "1").finish()); + package_database.add_package(Package::build("python", "3.13").finish()); + + let local_channel = package_database.into_channel().await.unwrap(); + + // Initialize a new pixi project using the above channel + let pixi = PixiControl::new().unwrap(); + pixi.init().with_channel(local_channel.url()).await.unwrap(); + + // Add the `packages` to the project + pixi.add("foobar").await.unwrap(); + pixi.add("python").await.unwrap(); + + let project = pixi.workspace().unwrap(); + + // Get the specs for the `python` package + let python_spec = project + .workspace + .value + .default_feature() + .dependencies(SpecType::Run, None) + .unwrap_or_default() + .get_single("python") + .unwrap() + .unwrap() + .clone() + .to_toml_value() + .to_string(); + // Testing to see if edge cases are handled correctly + // Python shouldn't be automatically pinned to a major version. + assert_eq!(python_spec, r#"">=3.13,<3.14""#); + + // Get the specs for the `foobar` package + let foobar_spec = project + .workspace + .value + .default_feature() + .dependencies(SpecType::Run, None) + .unwrap_or_default() + .get_single("foobar") + .unwrap() + .unwrap() + .clone() + .to_toml_value() + .to_string(); + assert_eq!(foobar_spec, r#"">=1,<2""#); + + // Add the `python` package with a specific version + pixi.add("python==3.13").await.unwrap(); + let project = pixi.workspace().unwrap(); + let python_spec = project + .workspace + .value + .default_feature() + .dependencies(SpecType::Run, None) + .unwrap_or_default() + .get_single("python") + .unwrap() + .unwrap() + .clone() + .to_toml_value() + .to_string(); + assert_eq!(python_spec, r#""==3.13""#); +} + +#[tokio::test] +async fn add_dependency_pinning_strategy() { + setup_tracing(); + + // Create a channel with two packages + let mut package_database = MockRepoData::default(); + package_database.add_package(Package::build("foo", "1").finish()); + package_database.add_package(Package::build("bar", "1").finish()); + package_database.add_package(Package::build("python", "3.13").finish()); + + let local_channel = package_database.into_channel().await.unwrap(); + + // Initialize a new pixi project using the above channel + let pixi = PixiControl::new().unwrap(); + pixi.init().with_channel(local_channel.url()).await.unwrap(); + + // Add the `packages` to the project + pixi.add_multiple(vec!["foo", "python", "bar"]) + .await + .unwrap(); + + let project = pixi.workspace().unwrap(); + + // Get the specs for the `foo` package + let foo_spec = project + .workspace + .value + .default_feature() + .dependencies(SpecType::Run, None) + .unwrap_or_default() + .get_single("foo") + .unwrap() + .unwrap() + .clone() + .to_toml_value() + .to_string(); + assert_eq!(foo_spec, r#"">=1,<2""#); + + // Get the specs for the `python` package + let python_spec = project + .workspace + .value + .default_feature() + .dependencies(SpecType::Run, None) + .unwrap_or_default() + .get_single("python") + .unwrap() + .unwrap() + .clone() + .to_toml_value() + .to_string(); + // Testing to see if edge cases are handled correctly + // Python shouldn't be automatically pinned to a major version. + assert_eq!(python_spec, r#"">=3.13,<3.14""#); + + // Get the specs for the `bar` package + let bar_spec = project + .workspace + .value + .default_feature() + .dependencies(SpecType::Run, None) + .unwrap_or_default() + .get_single("bar") + .unwrap() + .unwrap() + .clone() + .to_toml_value() + .to_string(); + // Testing to make sure bugfix did not regressed + // Package should be automatically pinned to a major version + assert_eq!(bar_spec, r#"">=1,<2""#); +} + +/// Test adding a git dependency with a specific branch (using local fixture) +#[tokio::test] +async fn add_git_deps() { + setup_tracing(); + + // Create local git fixture with passthrough backend + let fixture = GitRepoFixture::new("conda-build-package"); + let backend_override = BackendOverride::from_memory(PassthroughBackend::instantiator()); + + let pixi = PixiControl::from_manifest( + r#" +[workspace] +name = "test-channel-change" +channels = ["https://prefix.dev/conda-forge"] +platforms = ["win-64"] +preview = ['pixi-build'] +"#, + ) + .unwrap() + .with_backend_override(backend_override); + + // Add a package using local git fixture URL + pixi.add("boost-check") + .with_git_url(fixture.base_url.clone()) + .with_git_rev(GitRev::new().with_branch("main".to_string())) + .with_git_subdir("boost-check".to_string()) + .await + .unwrap(); + + let lock = pixi.lock_file().await.unwrap(); + let git_package = lock + .default_environment() + .unwrap() + .packages(Platform::Win64) + .unwrap() + .find(|p| p.as_conda().unwrap().location().as_str().contains("git+")); + + let location = git_package + .unwrap() + .as_conda() + .unwrap() + .location() + .to_string(); + + insta::with_settings!({filters => vec![ + (r"file://[^?#]+", "file://[TEMP_PATH]"), + (r"#[a-f0-9]+", "#[COMMIT]"), + ]}, { + insta::assert_snapshot!(location, @"git+file://[TEMP_PATH]?subdirectory=boost-check&branch=main#[COMMIT]"); + }); +} + +/// Test adding git dependencies with credentials +/// This tests is skipped on windows because it spawns a credential helper +/// during the CI run +#[cfg(not(windows))] +#[tokio::test] +#[cfg_attr(not(feature = "online_tests"), ignore)] +async fn add_git_deps_with_creds() { + setup_tracing(); + + let pixi = PixiControl::from_manifest( + r#" +[workspace] +name = "test-channel-change" +channels = ["https://prefix.dev/conda-forge"] +platforms = ["linux-64"] +preview = ['pixi-build'] +"#, + ) + .unwrap(); + + // Add a package + // we want to make sure that the credentials are not exposed in the lock file + pixi.add("boost-check") + .with_git_url( + Url::parse("https://user:token123@github.com/wolfv/pixi-build-examples.git").unwrap(), + ) + .with_git_rev(GitRev::new().with_branch("main".to_string())) + .with_git_subdir("boost-check".to_string()) + .await + .unwrap(); + + let lock = pixi.lock_file().await.unwrap(); + let git_package = lock + .default_environment() + .unwrap() + .packages(Platform::Linux64) + .unwrap() + .find(|p| p.as_conda().unwrap().location().as_str().contains("git+")); + + insta::with_settings!({filters => vec![ + (r"#([a-f0-9]+)", "#[FULL_COMMIT]"), + ]}, { + insta::assert_snapshot!(git_package.unwrap().as_conda().unwrap().location()); + + }); + + // Check the manifest itself + insta::assert_snapshot!( + pixi.workspace() + .unwrap() + .modify() + .unwrap() + .manifest() + .document + .to_string() + ); +} + +/// Test adding a git dependency with a specific commit (using local fixture) +#[tokio::test] +async fn add_git_with_specific_commit() { + setup_tracing(); + + // Create local git fixture with passthrough backend + let fixture = GitRepoFixture::new("conda-build-package"); + let backend_override = BackendOverride::from_memory(PassthroughBackend::instantiator()); + + let pixi = PixiControl::from_manifest( + r#" +[workspace] +name = "test-channel-change" +channels = ["https://prefix.dev/conda-forge"] +platforms = ["linux-64"] +preview = ['pixi-build']"#, + ) + .unwrap() + .with_backend_override(backend_override); + + // Add a package using the first commit from our fixture + let first_commit = fixture.first_commit().to_string(); + let short_commit = &first_commit[..7]; // Use short hash like the original test + + pixi.add("boost-check") + .with_git_url(fixture.base_url.clone()) + .with_git_rev(GitRev::new().with_rev(short_commit.to_string())) + .with_git_subdir("boost-check".to_string()) + .await + .unwrap(); + + // Check the lock file + let lock = pixi.lock_file().await.unwrap(); + let git_package = lock + .default_environment() + .unwrap() + .packages(Platform::Linux64) + .unwrap() + .find(|p| p.as_conda().unwrap().location().as_str().contains("git+")); + + let location = git_package + .unwrap() + .as_conda() + .unwrap() + .location() + .to_string(); + + insta::with_settings!({filters => vec![ + (r"file://[^?#]+", "file://[TEMP_PATH]"), + (r"rev=[a-f0-9]+", "rev=[SHORT_COMMIT]"), + (r"#[a-f0-9]+", "#[FULL_COMMIT]"), + ]}, { + insta::assert_snapshot!(location, @"git+file://[TEMP_PATH]?subdirectory=boost-check&rev=[SHORT_COMMIT]#[FULL_COMMIT]"); + }); +} + +/// Test adding a git dependency with a specific tag (using local fixture) +#[tokio::test] +async fn add_git_with_tag() { + setup_tracing(); + + // Create local git fixture with passthrough backend + // The fixture creates a tag "v0.1.0" for the second commit + let fixture = GitRepoFixture::new("conda-build-package"); + let backend_override = BackendOverride::from_memory(PassthroughBackend::instantiator()); + + let pixi = PixiControl::from_manifest( + r#" +[workspace] +name = "test-channel-change" +channels = ["https://prefix.dev/conda-forge"] +platforms = ["win-64"] +preview = ['pixi-build']"#, + ) + .unwrap() + .with_backend_override(backend_override); + + // Add a package using the tag from our fixture + let tag_commit = fixture.tag_commit("v0.1.0").to_string(); + + pixi.add("boost-check") + .with_git_url(fixture.base_url.clone()) + .with_git_rev(GitRev::new().with_tag("v0.1.0".to_string())) + .with_git_subdir("boost-check".to_string()) + .await + .unwrap(); + + // Check the lock file + let lock = pixi.lock_file().await.unwrap(); + let git_package = lock + .default_environment() + .unwrap() + .packages(Platform::Win64) + .unwrap() + .find(|p| p.as_conda().unwrap().location().as_str().contains("git+")); + + let location = git_package + .unwrap() + .as_conda() + .unwrap() + .location() + .to_string(); + + insta::with_settings!({filters => vec![ + (r"file://[^?#]+", "file://[TEMP_PATH]"), + (r"#[a-f0-9]+", "#[COMMIT]"), + ]}, { + insta::assert_snapshot!(location, @"git+file://[TEMP_PATH]?subdirectory=boost-check&tag=v0.1.0#[COMMIT]"); + }); + + // Verify the commit hash matches the tag's commit + assert!( + location.ends_with(&format!("#{tag_commit}")), + "Expected tag to resolve to commit {tag_commit}, got {location}" + ); +} + +/// Test adding a git dependency using ssh url +#[tokio::test] +async fn add_plain_ssh_url() { + setup_tracing(); + + let pixi = PixiControl::from_manifest( + r#" +[workspace] +name = "test-channel-change" +channels = ["https://prefix.dev/conda-forge"] +platforms = ["linux-64"] +preview = ['pixi-build']"#, + ) + .unwrap(); + + // Add a package + pixi.add("boost-check") + .with_git_url(Url::parse("git+ssh://git@github.com/wolfv/pixi-build-examples.git").unwrap()) + .with_install(false) + .with_frozen(true) + .await + .unwrap(); + + // Check the manifest itself + insta::assert_snapshot!( + pixi.workspace() + .unwrap() + .workspace + .provenance + .read() + .unwrap() + .into_inner() + ); +} + +/// Test adding a git dependency using ssh url +#[tokio::test] +#[cfg_attr(not(feature = "online_tests"), ignore)] +async fn add_pypi_git() { + setup_tracing(); + + let pixi = PixiControl::from_manifest( + format!( + r#" +[workspace] +name = "test-channel-change" +channels = ["https://prefix.dev/conda-forge"] +platforms = ["{platform}"] + +"#, + platform = Platform::current() + ) + .as_str(), + ) + .unwrap(); + + // Add python + pixi.add("python>=3.13.2,<3.14").await.unwrap(); + + // Add a package + pixi.add("boltons") + .set_pypi(true) + .with_git_url(Url::parse("https://github.com/mahmoud/boltons.git").unwrap()) + .await + .unwrap(); + + // Check the manifest itself + insta::with_settings!({filters => vec![ + (r"#([a-f0-9]+)", "#[FULL_COMMIT]"), + (r"platforms = \[.*\]", "platforms = [\"\"]"), + ]}, { + insta::assert_snapshot!(pixi.workspace().unwrap().workspace.provenance.read().unwrap().into_inner()); + }); + + let lock_file = pixi.lock_file().await.unwrap(); + + let (boltons, _) = lock_file + .default_environment() + .unwrap() + .pypi_packages(Platform::current()) + .unwrap() + .find(|(p, _)| p.name.to_string() == "boltons") + .unwrap(); + + insta::with_settings!( {filters => vec![ + (r"#([a-f0-9]+)", "#[FULL_COMMIT]"), + ]}, { + insta::assert_snapshot!(boltons.location); + }); +} + +#[tokio::test] +async fn add_git_dependency_without_preview_feature_fails() { + setup_tracing(); + + let pixi = PixiControl::from_manifest( + r#" +[workspace] +name = "test-git-no-preview" +channels = ["https://prefix.dev/conda-forge"] +platforms = ["linux-64"] +"#, + ) + .unwrap(); + + let result = pixi + .add("boost-check") + .with_git_url(Url::parse("https://github.com/wolfv/pixi-build-examples.git").unwrap()) + .with_git_subdir("boost-check".to_string()) + .await; + + assert!(result.is_err()); + let error = result.unwrap_err(); + + // Use insta to snapshot test the full error message format including help text + insta::with_settings!({ + filters => vec![ + // Filter out the dynamic manifest path to make the snapshot stable + (r"manifest \([^)]+\)", "manifest ()"), + ] + }, { + insta::assert_debug_snapshot!("git_dependency_without_preview_error", error); + }); +} + +#[tokio::test] +async fn add_git_dependency_with_preview_feature_succeeds() { + setup_tracing(); + + let pixi = PixiControl::from_manifest( + r#" +[workspace] +name = "test-git-with-preview" +channels = ["https://prefix.dev/conda-forge"] +platforms = ["linux-64"] +preview = ["pixi-build"] +"#, + ) + .unwrap(); + + let result = pixi + .add("boost-check") + .with_git_url(Url::parse("https://github.com/wolfv/pixi-build-examples.git").unwrap()) + .with_git_subdir("boost-check".to_string()) + .with_install(false) + .with_frozen(true) + .await; + + assert!(result.is_ok()); + + let workspace = pixi.workspace().unwrap(); + let deps = workspace + .default_environment() + .combined_dependencies(Some(Platform::Linux64)); + + let (name, spec) = deps + .into_specs() + .find(|(name, _)| name.as_normalized() == "boost-check") + .unwrap(); + assert_eq!(name.as_normalized(), "boost-check"); + assert!(spec.is_source()); +} + +#[tokio::test] +async fn add_dependency_dont_create_project() { + setup_tracing(); + + // Create a channel with two packages + let mut package_database = MockRepoData::default(); + package_database.add_package(Package::build("foo", "1").finish()); + package_database.add_package(Package::build("bar", "1").finish()); + package_database.add_package(Package::build("python", "3.13").finish()); + + let local_channel = package_database.into_channel().await.unwrap(); + + let local_channel_str = format!("{}", local_channel.url()); + + // Initialize a new pixi project using the above channel + let pixi = PixiControl::from_manifest(&format!( + r#" +[workspace] +name = "some-workspace" +platforms = [] +channels = ['{local_channel_str}'] +preview = ['pixi-build'] +"# + )) + .unwrap(); + + // Add the `packages` to the project + pixi.add("foo").await.unwrap(); + + let workspace = pixi.workspace().unwrap(); + + // filter out local channels from the insta + insta::with_settings!({filters => vec![ + (local_channel_str.as_str(), "file:///"), + ]}, { + insta::assert_snapshot!(workspace.workspace.provenance.read().unwrap().into_inner()); + }); + + /// Test the `pixi add --pypi --index` functionality + #[cfg(unix)] + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn add_pypi_with_index() { + use crate::common::pypi_index::{Database as PyPIDatabase, PyPIPackage}; + + setup_tracing(); + + // Create local PyPI index with test package + let pypi_index = PyPIDatabase::new() + .with(PyPIPackage::new("requests", "2.32.0")) + .into_simple_index() + .unwrap(); + + // Create local conda channel with Python + let mut package_db = MockRepoData::default(); + package_db.add_package( + Package::build("python", "3.12.0") + .with_subdir(Platform::current()) + .finish(), + ); + let channel = package_db.into_channel().await.unwrap(); + + let pixi = PixiControl::new().unwrap(); + + pixi.init() + .without_channels() + .with_local_channel(channel.url().to_file_path().unwrap()) + .await + .unwrap(); + + // Add python + pixi.add("python==3.12.0") + .set_type(DependencyType::CondaDependency(SpecType::Run)) + .await + .unwrap(); + + // Add a pypi package with custom index + let custom_index = pypi_index.index_url().to_string(); + pixi.add("requests") + .set_type(DependencyType::PypiDependency) + .with_index(custom_index.clone()) + .await + .unwrap(); + + // Read project and check if index is set + let project = Workspace::from_path(pixi.manifest_path().as_path()).unwrap(); + let pypi_deps: Vec<_> = project + .default_environment() + .pypi_dependencies(None) + .into_specs() + .collect(); + + // Find the requests package + let (_name, spec) = pypi_deps + .iter() + .find(|(name, _)| *name == PypiPackageName::from_str("requests").unwrap()) + .expect("requests package should be in dependencies"); + + // Verify the index is set correctly + if let PixiPypiSpec::Version { index, .. } = spec { + assert_eq!( + index.as_ref().map(|u| u.as_str()), + Some(custom_index.as_str()), + "Index URL should match the provided custom index" + ); + } else { + panic!("Expected PixiPypiSpec::Version variant"); + } + } +} diff --git a/crates/pixi/tests/integration_rust/common/builders.rs b/crates/pixi/tests/integration_rust/common/builders.rs index 24975057c6..c5a7f7c599 100644 --- a/crates/pixi/tests/integration_rust/common/builders.rs +++ b/crates/pixi/tests/integration_rust/common/builders.rs @@ -1,717 +1,722 @@ -//! Contains builders for the CLI commands -//! We are using a builder pattern here to make it easier to write tests. -//! And are kinda abusing the `IntoFuture` trait to make it easier to execute as -//! close as we can get to the command line args -//! -//! # Using IntoFuture -//! -//! When `.await` is called on an object that is not a `Future` the compiler -//! will first check if the type implements `IntoFuture`. If it does it will -//! call the `IntoFuture::into_future()` method and await the resulting -//! `Future`. We can abuse this behavior in builder patterns because the -//! `into_future` method can also be used as a `finish` function. This allows -//! you to reduce the required code. -//! -//! ```rust -//! impl IntoFuture for InitBuilder { -//! type Output = miette::Result<()>; -//! type IntoFuture = Pin + Send + 'static>>; -//! -//! fn into_future(self) -> Self::IntoFuture { -//! Box::pin(init::execute(self.args)) -//! } -//! } -//! ``` - -use pixi_cli::{ - add, build, - cli_config::{ - DependencyConfig, GitRev, LockFileUpdateConfig, NoInstallConfig, WorkspaceConfig, - }, - global, init, install, lock, remove, search, task, update, workspace, -}; -use pixi_core::DependencyType; -use std::{ - future::{Future, IntoFuture}, - io, - path::{Path, PathBuf}, - pin::Pin, - str::FromStr, -}; -use typed_path::Utf8NativePathBuf; - -use futures::FutureExt; -use pixi_manifest::{EnvironmentName, FeatureName, SpecType, task::Dependency}; -use rattler_conda_types::{NamedChannelOrUrl, Platform, RepoDataRecord}; -use url::Url; - -/// Strings from an iterator -pub fn string_from_iter(iter: impl IntoIterator>) -> Vec { - iter.into_iter().map(|s| s.as_ref().to_string()).collect() -} - -/// Contains the arguments to pass to [`init::execute()`]. Call `.await` to call -/// the CLI execute method and await the result at the same time. -pub struct InitBuilder { - pub args: init::Args, - pub no_fast_prefix: bool, -} - -impl InitBuilder { - /// Disable using `https://prefix.dev` as the default channel. - pub fn no_fast_prefix_overwrite(self, no_fast_prefix: bool) -> Self { - Self { - no_fast_prefix, - ..self - } - } - - pub fn with_channel(mut self, channel: impl ToString) -> Self { - self.args - .channels - .get_or_insert_with(Default::default) - .push(NamedChannelOrUrl::from_str(channel.to_string().as_str()).unwrap()); - self - } - - pub fn with_local_channel(self, channel: impl AsRef) -> Self { - self.with_channel(Url::from_directory_path(channel).unwrap()) - } - - pub fn without_channels(mut self) -> Self { - self.args.channels = Some(vec![]); - self - } - - /// Instruct init which manifest format to use - pub fn with_format(mut self, format: init::ManifestFormat) -> Self { - self.args.format = Some(format); - self - } - - pub fn with_platforms(mut self, platforms: Vec) -> Self { - self.args.platforms = platforms.into_iter().map(|p| p.to_string()).collect(); - self - } -} - -impl IntoFuture for InitBuilder { - type Output = miette::Result<()>; - type IntoFuture = Pin + 'static>>; - - fn into_future(self) -> Self::IntoFuture { - init::execute(init::Args { - channels: if !self.no_fast_prefix { - self.args.channels.or_else(|| { - Some(vec![ - NamedChannelOrUrl::from_str("https://prefix.dev/conda-forge").unwrap(), - ]) - }) - } else { - self.args.channels - }, - ..self.args - }) - .boxed_local() - } -} -/// A trait used by builders to access NoInstallConfig -pub trait HasNoInstallConfig: Sized { - fn no_install_config(&mut self) -> &mut NoInstallConfig; - /// Set whether to also install the environment. By default, the environment - /// is NOT installed to reduce test times. - fn with_install(mut self, install: bool) -> Self { - self.no_install_config().no_install = !install; - self - } -} - -/// A trait used by AddBuilder and RemoveBuilder to set their inner -/// DependencyConfig -pub trait HasLockFileUpdateConfig: Sized { - fn lock_file_update_config(&mut self) -> &mut LockFileUpdateConfig; - - /// Set the frozen flag to skip lock-file updates - fn with_frozen(mut self, frozen: bool) -> Self { - self.lock_file_update_config().lock_file_usage.frozen = frozen; - self - } -} - -/// A trait used by AddBuilder and RemoveBuilder to set their inner -/// DependencyConfig -pub trait HasDependencyConfig: Sized { - fn dependency_config(&mut self) -> &mut DependencyConfig; - - fn dependency_config_with_specs(specs: Vec<&str>) -> DependencyConfig { - DependencyConfig { - specs: specs.iter().map(|s| s.to_string()).collect(), - host: false, - build: false, - pypi: false, - platforms: Default::default(), - feature: Default::default(), - git: Default::default(), - rev: Default::default(), - subdir: Default::default(), - } - } - - fn with_spec(mut self, spec: &str) -> Self { - self.dependency_config().specs.push(spec.to_string()); - self - } - - /// Set as a host - fn set_type(mut self, t: DependencyType) -> Self { - match t { - DependencyType::CondaDependency(spec_type) => match spec_type { - SpecType::Host => { - self.dependency_config().host = true; - self.dependency_config().build = false; - } - SpecType::Build => { - self.dependency_config().host = false; - self.dependency_config().build = true; - } - SpecType::Run => { - self.dependency_config().host = false; - self.dependency_config().build = false; - } - }, - DependencyType::PypiDependency => { - self.dependency_config().host = false; - self.dependency_config().build = false; - self.dependency_config().pypi = true; - } - } - self - } - - fn set_platforms(mut self, platforms: &[Platform]) -> Self { - self.dependency_config().platforms.extend(platforms.iter()); - self - } -} - -/// Contains the arguments to pass to [`add::execute()`]. Call `.await` to call -/// the CLI execute method and await the result at the same time. -pub struct AddBuilder { - pub args: add::Args, -} - -impl AddBuilder { - pub fn set_editable(mut self, editable: bool) -> Self { - self.args.editable = editable; - self - } - - pub fn set_pypi(mut self, pypi: bool) -> Self { - self.args.dependency_config.pypi = pypi; - self - } - - pub fn with_feature(mut self, feature: impl ToString) -> Self { - self.args.dependency_config.feature = FeatureName::from(feature.to_string()); - self - } - - pub fn with_platform(mut self, platform: Platform) -> Self { - self.args.dependency_config.platforms.push(platform); - self - } - - pub fn with_git_url(mut self, url: Url) -> Self { - self.args.dependency_config.git = Some(url); - self - } - - pub fn with_git_rev(mut self, rev: GitRev) -> Self { - self.args.dependency_config.rev = Some(rev); - self - } - - pub fn with_git_subdir(mut self, subdir: String) -> Self { - self.args.dependency_config.subdir = Some(subdir); - self - } - - /// Deprecated: Use .with_frozen(true).with_install(false) instead - pub fn with_no_lockfile_update(mut self, no_lockfile_update: bool) -> Self { - if no_lockfile_update { - // Since no_lockfile_update is deprecated, we simulate the behavior by setting frozen=true and no_install=true - self.args.lock_file_update_config.lock_file_usage.frozen = true; - self.args.no_install_config.no_install = true; - } - self - } - - pub fn with_no_install(mut self, no_install: bool) -> Self { - self.args.no_install_config.no_install = no_install; - self - } -} - -impl HasDependencyConfig for AddBuilder { - fn dependency_config(&mut self) -> &mut DependencyConfig { - &mut self.args.dependency_config - } -} - -impl HasNoInstallConfig for AddBuilder { - fn no_install_config(&mut self) -> &mut NoInstallConfig { - &mut self.args.no_install_config - } -} - -impl HasLockFileUpdateConfig for AddBuilder { - fn lock_file_update_config(&mut self) -> &mut LockFileUpdateConfig { - &mut self.args.lock_file_update_config - } -} - -impl IntoFuture for AddBuilder { - type Output = miette::Result<()>; - type IntoFuture = Pin + 'static>>; - - fn into_future(self) -> Self::IntoFuture { - add::execute(self.args).boxed_local() - } -} - -/// Contains the arguments to pass to [`search::execute()`]. Call `.await` to call -/// the CLI execute method and await the result at the same time. -pub struct SearchBuilder { - pub args: search::Args, -} - -impl IntoFuture for SearchBuilder { - type Output = miette::Result>>; - type IntoFuture = Pin + 'static>>; - - fn into_future(self) -> Self::IntoFuture { - Box::pin(async move { - let mut out = io::stdout(); - search::execute_impl(self.args, &mut out).await - }) - } -} - -/// Contains the arguments to pass to [`remove::execute()`]. Call `.await` to -/// call the CLI execute method and await the result at the same time. -pub struct RemoveBuilder { - pub args: remove::Args, -} - -impl HasDependencyConfig for RemoveBuilder { - fn dependency_config(&mut self) -> &mut DependencyConfig { - &mut self.args.dependency_config - } -} - -impl HasNoInstallConfig for RemoveBuilder { - fn no_install_config(&mut self) -> &mut NoInstallConfig { - &mut self.args.no_install_config - } -} - -impl IntoFuture for RemoveBuilder { - type Output = miette::Result<()>; - type IntoFuture = Pin + 'static>>; - - fn into_future(self) -> Self::IntoFuture { - remove::execute(self.args).boxed_local() - } -} -pub struct TaskAddBuilder { - pub manifest_path: Option, - pub args: task::AddArgs, -} - -impl TaskAddBuilder { - /// Execute these commands - pub fn with_commands(mut self, commands: impl IntoIterator>) -> Self { - self.args.commands = string_from_iter(commands); - self - } - - /// Depends on these commands - pub fn with_depends_on(mut self, depends: Vec) -> Self { - self.args.depends_on = Some(depends); - self - } - - /// With this working directory - pub fn with_cwd(mut self, cwd: PathBuf) -> Self { - self.args.cwd = Some(cwd); - self - } - - /// With this environment variable - pub fn with_env(mut self, env: Vec<(String, String)>) -> Self { - self.args.env = env; - self - } - - /// Execute the CLI command - pub async fn execute(self) -> miette::Result<()> { - task::execute(task::Args { - operation: task::Operation::Add(self.args), - workspace_config: WorkspaceConfig { - manifest_path: self.manifest_path, - ..Default::default() - }, - }) - .await - } -} - -pub struct TaskAliasBuilder { - pub manifest_path: Option, - pub args: task::AliasArgs, -} - -impl TaskAliasBuilder { - /// Depends on these commands - pub fn with_depends_on(mut self, depends: Vec) -> Self { - self.args.depends_on = depends; - self - } - - /// Execute the CLI command - pub async fn execute(self) -> miette::Result<()> { - task::execute(task::Args { - operation: task::Operation::Alias(self.args), - workspace_config: WorkspaceConfig { - manifest_path: self.manifest_path, - ..Default::default() - }, - }) - .await - } -} - -pub struct ProjectChannelAddBuilder { - pub args: workspace::channel::AddRemoveArgs, -} - -impl ProjectChannelAddBuilder { - /// Adds the specified channel - pub fn with_channel(mut self, name: impl Into) -> Self { - self.args - .channel - .push(NamedChannelOrUrl::from_str(&name.into()).unwrap()); - self - } - - pub fn with_priority(mut self, priority: Option) -> Self { - self.args.priority = priority; - self - } - - /// Alias to add a local channel. - pub fn with_local_channel(self, channel: impl AsRef) -> Self { - self.with_channel(Url::from_directory_path(channel).unwrap()) - } -} - -impl IntoFuture for ProjectChannelAddBuilder { - type Output = miette::Result<()>; - type IntoFuture = Pin + 'static>>; - - fn into_future(self) -> Self::IntoFuture { - workspace::channel::execute(workspace::channel::Args { - command: workspace::channel::Command::Add(self.args), - }) - .boxed_local() - } -} - -pub struct ProjectChannelRemoveBuilder { - pub manifest_path: Option, - pub args: workspace::channel::AddRemoveArgs, -} - -impl ProjectChannelRemoveBuilder { - /// Removes the specified channel - pub fn with_channel(mut self, name: impl Into) -> Self { - self.args - .channel - .push(NamedChannelOrUrl::from_str(&name.into()).unwrap()); - self - } - - /// Alias to Remove a local channel. - pub fn with_local_channel(self, channel: impl AsRef) -> Self { - self.with_channel(Url::from_directory_path(channel).unwrap()) - } -} - -impl IntoFuture for ProjectChannelRemoveBuilder { - type Output = miette::Result<()>; - type IntoFuture = Pin + 'static>>; - - fn into_future(self) -> Self::IntoFuture { - workspace::channel::execute(workspace::channel::Args { - command: workspace::channel::Command::Remove(self.args), - }) - .boxed_local() - } -} - -/// Contains the arguments to pass to [`install::execute()`]. Call `.await` to -/// call the CLI execute method and await the result at the same time. -pub struct InstallBuilder { - pub args: install::Args, -} - -impl InstallBuilder { - pub fn with_locked(mut self) -> Self { - self.args.lock_file_usage.locked = true; - self - } - pub fn with_frozen(mut self) -> Self { - self.args.lock_file_usage.frozen = true; - self - } - pub fn with_skipped(mut self, names: Vec) -> Self { - self.args.skip = Some(names); - self - } - pub fn with_skipped_with_deps(mut self, names: Vec) -> Self { - self.args.skip_with_deps = Some(names); - self - } - pub fn with_only_package(mut self, pkg: Vec) -> Self { - self.args.only = Some(pkg); - self - } -} - -impl IntoFuture for InstallBuilder { - type Output = miette::Result<()>; - type IntoFuture = Pin + 'static>>; - fn into_future(self) -> Self::IntoFuture { - install::execute(self.args).boxed_local() - } -} - -pub struct ProjectEnvironmentAddBuilder { - pub args: workspace::environment::AddArgs, - pub manifest_path: Option, -} - -impl ProjectEnvironmentAddBuilder { - pub fn with_feature(mut self, feature: impl Into) -> Self { - self.args - .features - .get_or_insert_with(Vec::new) - .push(feature.into()); - self - } - - pub fn with_no_default_features(mut self, no_default_features: bool) -> Self { - self.args.no_default_feature = no_default_features; - self - } - - pub fn force(mut self, force: bool) -> Self { - self.args.force = force; - self - } - - pub fn with_solve_group(mut self, solve_group: impl Into) -> Self { - self.args.solve_group = Some(solve_group.into()); - self - } -} - -impl IntoFuture for ProjectEnvironmentAddBuilder { - type Output = miette::Result<()>; - type IntoFuture = Pin + 'static>>; - fn into_future(self) -> Self::IntoFuture { - workspace::environment::execute(workspace::environment::Args { - workspace_config: WorkspaceConfig { - manifest_path: self.manifest_path, - ..Default::default() - }, - command: workspace::environment::Command::Add(self.args), - }) - .boxed_local() - } -} - -/// Contains the arguments to pass to [`update::execute()`]. Call `.await` to -/// call the CLI execute method and await the result at the same time. -pub struct UpdateBuilder { - pub args: update::Args, -} - -impl UpdateBuilder { - pub fn with_package(mut self, package: impl ToString) -> Self { - self.args - .specs - .packages - .get_or_insert_with(Vec::new) - .push(package.to_string()); - self - } - - pub fn with_environment(mut self, env: impl Into) -> Self { - self.args - .specs - .environments - .get_or_insert_with(Vec::new) - .push(env.into()); - self - } - - pub fn with_platform(mut self, platform: Platform) -> Self { - self.args - .specs - .platforms - .get_or_insert_with(Vec::new) - .push(platform); - self - } - - pub fn dry_run(mut self, dry_run: bool) -> Self { - self.args.dry_run = dry_run; - self - } - - pub fn json(mut self, json: bool) -> Self { - self.args.json = json; - self - } - - pub fn with_no_install(mut self, no_install: bool) -> Self { - self.args.no_install = no_install; - self - } -} - -impl IntoFuture for UpdateBuilder { - type Output = miette::Result<()>; - type IntoFuture = Pin + 'static>>; - fn into_future(self) -> Self::IntoFuture { - update::execute(self.args).boxed_local() - } -} - -/// Contains the arguments to pass to [`lock::execute()`]. Call `.await` to call -/// the CLI execute method and await the result at the same time. -pub struct LockBuilder { - pub args: lock::Args, -} - -impl IntoFuture for LockBuilder { - type Output = miette::Result<()>; - type IntoFuture = Pin + 'static>>; - - fn into_future(self) -> Self::IntoFuture { - lock::execute(self.args).boxed_local() - } -} - -/// Contains the arguments to pass to [`build::execute()`]. Call `.await` to call -/// the CLI execute method and await the result at the same time. -pub struct BuildBuilder { - pub args: build::Args, -} - -impl BuildBuilder { - /// Set the target platform for the build - pub fn with_target_platform(mut self, platform: Platform) -> Self { - self.args.target_platform = platform; - self - } - - /// Set the build platform for the build - pub fn with_build_platform(mut self, platform: Platform) -> Self { - self.args.build_platform = platform; - self - } - - /// Set the output directory for built artifacts - pub fn with_output_dir(mut self, output_dir: impl Into) -> Self { - self.args.output_dir = output_dir.into(); - self - } - - /// Set the build directory for incremental builds - pub fn with_build_dir(mut self, build_dir: impl Into) -> Self { - self.args.build_dir = Some(build_dir.into()); - self - } - - /// Set whether to clean the build directory before building - pub fn with_clean(mut self, clean: bool) -> Self { - self.args.clean = clean; - self - } - - /// Set the path to the package manifest or directory - pub fn with_path(mut self, path: impl Into) -> Self { - self.args.path = Some(path.into()); - self - } -} - -impl IntoFuture for BuildBuilder { - type Output = miette::Result<()>; - type IntoFuture = Pin + 'static>>; - - fn into_future(self) -> Self::IntoFuture { - build::execute(self.args).boxed_local() - } -} - -/// Contains the arguments to pass to [`global::execute()`]. Call `.await` to call -/// the CLI execute method and await the result at the same time. -pub struct GlobalInstallBuilder { - args: global::install::Args, - tmpdir: PathBuf, -} - -impl GlobalInstallBuilder { - /// Create a new GlobalInstallBuilder - pub fn new( - tmpdir: PathBuf, - backend_override: Option, - ) -> Self { - let mut args = global::install::Args::default(); - args.backend_override = backend_override; - Self { args, tmpdir } - } - - pub fn with_path(mut self, path: impl ToString) -> Self { - self.args.packages.path = Some(Into::::into(path.to_string())); - self - } - - pub fn with_force_reinstall(mut self, force_reinstall: bool) -> Self { - self.args.force_reinstall = force_reinstall; - self - } -} - -impl IntoFuture for GlobalInstallBuilder { - type Output = miette::Result<()>; - type IntoFuture = Pin + 'static>>; - - fn into_future(self) -> Self::IntoFuture { - let args = global::Args { - command: global::Command::Install(self.args), - }; - - temp_env::async_with_vars( - [ - ("PIXI_HOME", Some(self.tmpdir.clone())), - ("PIXI_CACHE_DIR", Some(self.tmpdir.clone())), - ], - async { global::execute(args).await }, - ) - .boxed_local() - } -} +//! Contains builders for the CLI commands +//! We are using a builder pattern here to make it easier to write tests. +//! And are kinda abusing the `IntoFuture` trait to make it easier to execute as +//! close as we can get to the command line args +//! +//! # Using IntoFuture +//! +//! When `.await` is called on an object that is not a `Future` the compiler +//! will first check if the type implements `IntoFuture`. If it does it will +//! call the `IntoFuture::into_future()` method and await the resulting +//! `Future`. We can abuse this behavior in builder patterns because the +//! `into_future` method can also be used as a `finish` function. This allows +//! you to reduce the required code. +//! +//! ```rust +//! impl IntoFuture for InitBuilder { +//! type Output = miette::Result<()>; +//! type IntoFuture = Pin + Send + 'static>>; +//! +//! fn into_future(self) -> Self::IntoFuture { +//! Box::pin(init::execute(self.args)) +//! } +//! } +//! ``` + +use pixi_cli::{ + add, build, + cli_config::{ + DependencyConfig, GitRev, LockFileUpdateConfig, NoInstallConfig, WorkspaceConfig, + }, + global, init, install, lock, remove, search, task, update, workspace, +}; +use pixi_core::DependencyType; +use std::{ + future::{Future, IntoFuture}, + io, + path::{Path, PathBuf}, + pin::Pin, + str::FromStr, +}; +use typed_path::Utf8NativePathBuf; + +use futures::FutureExt; +use pixi_manifest::{EnvironmentName, FeatureName, SpecType, task::Dependency}; +use rattler_conda_types::{NamedChannelOrUrl, Platform, RepoDataRecord}; +use url::Url; + +/// Strings from an iterator +pub fn string_from_iter(iter: impl IntoIterator>) -> Vec { + iter.into_iter().map(|s| s.as_ref().to_string()).collect() +} + +/// Contains the arguments to pass to [`init::execute()`]. Call `.await` to call +/// the CLI execute method and await the result at the same time. +pub struct InitBuilder { + pub args: init::Args, + pub no_fast_prefix: bool, +} + +impl InitBuilder { + /// Disable using `https://prefix.dev` as the default channel. + pub fn no_fast_prefix_overwrite(self, no_fast_prefix: bool) -> Self { + Self { + no_fast_prefix, + ..self + } + } + + pub fn with_channel(mut self, channel: impl ToString) -> Self { + self.args + .channels + .get_or_insert_with(Default::default) + .push(NamedChannelOrUrl::from_str(channel.to_string().as_str()).unwrap()); + self + } + + pub fn with_local_channel(self, channel: impl AsRef) -> Self { + self.with_channel(Url::from_directory_path(channel).unwrap()) + } + + pub fn without_channels(mut self) -> Self { + self.args.channels = Some(vec![]); + self + } + + /// Instruct init which manifest format to use + pub fn with_format(mut self, format: init::ManifestFormat) -> Self { + self.args.format = Some(format); + self + } + + pub fn with_platforms(mut self, platforms: Vec) -> Self { + self.args.platforms = platforms.into_iter().map(|p| p.to_string()).collect(); + self + } +} + +impl IntoFuture for InitBuilder { + type Output = miette::Result<()>; + type IntoFuture = Pin + 'static>>; + + fn into_future(self) -> Self::IntoFuture { + init::execute(init::Args { + channels: if !self.no_fast_prefix { + self.args.channels.or_else(|| { + Some(vec![ + NamedChannelOrUrl::from_str("https://prefix.dev/conda-forge").unwrap(), + ]) + }) + } else { + self.args.channels + }, + ..self.args + }) + .boxed_local() + } +} +/// A trait used by builders to access NoInstallConfig +pub trait HasNoInstallConfig: Sized { + fn no_install_config(&mut self) -> &mut NoInstallConfig; + /// Set whether to also install the environment. By default, the environment + /// is NOT installed to reduce test times. + fn with_install(mut self, install: bool) -> Self { + self.no_install_config().no_install = !install; + self + } +} + +/// A trait used by AddBuilder and RemoveBuilder to set their inner +/// DependencyConfig +pub trait HasLockFileUpdateConfig: Sized { + fn lock_file_update_config(&mut self) -> &mut LockFileUpdateConfig; + + /// Set the frozen flag to skip lock-file updates + fn with_frozen(mut self, frozen: bool) -> Self { + self.lock_file_update_config().lock_file_usage.frozen = frozen; + self + } +} + +/// A trait used by AddBuilder and RemoveBuilder to set their inner +/// DependencyConfig +pub trait HasDependencyConfig: Sized { + fn dependency_config(&mut self) -> &mut DependencyConfig; + + fn dependency_config_with_specs(specs: Vec<&str>) -> DependencyConfig { + DependencyConfig { + specs: specs.iter().map(|s| s.to_string()).collect(), + host: false, + build: false, + pypi: false, + platforms: Default::default(), + feature: Default::default(), + git: Default::default(), + rev: Default::default(), + subdir: Default::default(), + } + } + + fn with_spec(mut self, spec: &str) -> Self { + self.dependency_config().specs.push(spec.to_string()); + self + } + + /// Set as a host + fn set_type(mut self, t: DependencyType) -> Self { + match t { + DependencyType::CondaDependency(spec_type) => match spec_type { + SpecType::Host => { + self.dependency_config().host = true; + self.dependency_config().build = false; + } + SpecType::Build => { + self.dependency_config().host = false; + self.dependency_config().build = true; + } + SpecType::Run => { + self.dependency_config().host = false; + self.dependency_config().build = false; + } + }, + DependencyType::PypiDependency => { + self.dependency_config().host = false; + self.dependency_config().build = false; + self.dependency_config().pypi = true; + } + } + self + } + + fn set_platforms(mut self, platforms: &[Platform]) -> Self { + self.dependency_config().platforms.extend(platforms.iter()); + self + } +} + +/// Contains the arguments to pass to [`add::execute()`]. Call `.await` to call +/// the CLI execute method and await the result at the same time. +pub struct AddBuilder { + pub args: add::Args, +} + +impl AddBuilder { + pub fn set_editable(mut self, editable: bool) -> Self { + self.args.editable = editable; + self + } + + pub fn set_pypi(mut self, pypi: bool) -> Self { + self.args.dependency_config.pypi = pypi; + self + } + + pub fn with_feature(mut self, feature: impl ToString) -> Self { + self.args.dependency_config.feature = FeatureName::from(feature.to_string()); + self + } + + pub fn with_platform(mut self, platform: Platform) -> Self { + self.args.dependency_config.platforms.push(platform); + self + } + + pub fn with_git_url(mut self, url: Url) -> Self { + self.args.dependency_config.git = Some(url); + self + } + + pub fn with_git_rev(mut self, rev: GitRev) -> Self { + self.args.dependency_config.rev = Some(rev); + self + } + + pub fn with_git_subdir(mut self, subdir: String) -> Self { + self.args.dependency_config.subdir = Some(subdir); + self + } + + pub fn with_index(mut self, index: String) -> Self { + self.args.index = Some(index); + self + } + + /// Deprecated: Use .with_frozen(true).with_install(false) instead + pub fn with_no_lockfile_update(mut self, no_lockfile_update: bool) -> Self { + if no_lockfile_update { + // Since no_lockfile_update is deprecated, we simulate the behavior by setting frozen=true and no_install=true + self.args.lock_file_update_config.lock_file_usage.frozen = true; + self.args.no_install_config.no_install = true; + } + self + } + + pub fn with_no_install(mut self, no_install: bool) -> Self { + self.args.no_install_config.no_install = no_install; + self + } +} + +impl HasDependencyConfig for AddBuilder { + fn dependency_config(&mut self) -> &mut DependencyConfig { + &mut self.args.dependency_config + } +} + +impl HasNoInstallConfig for AddBuilder { + fn no_install_config(&mut self) -> &mut NoInstallConfig { + &mut self.args.no_install_config + } +} + +impl HasLockFileUpdateConfig for AddBuilder { + fn lock_file_update_config(&mut self) -> &mut LockFileUpdateConfig { + &mut self.args.lock_file_update_config + } +} + +impl IntoFuture for AddBuilder { + type Output = miette::Result<()>; + type IntoFuture = Pin + 'static>>; + + fn into_future(self) -> Self::IntoFuture { + add::execute(self.args).boxed_local() + } +} + +/// Contains the arguments to pass to [`search::execute()`]. Call `.await` to call +/// the CLI execute method and await the result at the same time. +pub struct SearchBuilder { + pub args: search::Args, +} + +impl IntoFuture for SearchBuilder { + type Output = miette::Result>>; + type IntoFuture = Pin + 'static>>; + + fn into_future(self) -> Self::IntoFuture { + Box::pin(async move { + let mut out = io::stdout(); + search::execute_impl(self.args, &mut out).await + }) + } +} + +/// Contains the arguments to pass to [`remove::execute()`]. Call `.await` to +/// call the CLI execute method and await the result at the same time. +pub struct RemoveBuilder { + pub args: remove::Args, +} + +impl HasDependencyConfig for RemoveBuilder { + fn dependency_config(&mut self) -> &mut DependencyConfig { + &mut self.args.dependency_config + } +} + +impl HasNoInstallConfig for RemoveBuilder { + fn no_install_config(&mut self) -> &mut NoInstallConfig { + &mut self.args.no_install_config + } +} + +impl IntoFuture for RemoveBuilder { + type Output = miette::Result<()>; + type IntoFuture = Pin + 'static>>; + + fn into_future(self) -> Self::IntoFuture { + remove::execute(self.args).boxed_local() + } +} +pub struct TaskAddBuilder { + pub manifest_path: Option, + pub args: task::AddArgs, +} + +impl TaskAddBuilder { + /// Execute these commands + pub fn with_commands(mut self, commands: impl IntoIterator>) -> Self { + self.args.commands = string_from_iter(commands); + self + } + + /// Depends on these commands + pub fn with_depends_on(mut self, depends: Vec) -> Self { + self.args.depends_on = Some(depends); + self + } + + /// With this working directory + pub fn with_cwd(mut self, cwd: PathBuf) -> Self { + self.args.cwd = Some(cwd); + self + } + + /// With this environment variable + pub fn with_env(mut self, env: Vec<(String, String)>) -> Self { + self.args.env = env; + self + } + + /// Execute the CLI command + pub async fn execute(self) -> miette::Result<()> { + task::execute(task::Args { + operation: task::Operation::Add(self.args), + workspace_config: WorkspaceConfig { + manifest_path: self.manifest_path, + ..Default::default() + }, + }) + .await + } +} + +pub struct TaskAliasBuilder { + pub manifest_path: Option, + pub args: task::AliasArgs, +} + +impl TaskAliasBuilder { + /// Depends on these commands + pub fn with_depends_on(mut self, depends: Vec) -> Self { + self.args.depends_on = depends; + self + } + + /// Execute the CLI command + pub async fn execute(self) -> miette::Result<()> { + task::execute(task::Args { + operation: task::Operation::Alias(self.args), + workspace_config: WorkspaceConfig { + manifest_path: self.manifest_path, + ..Default::default() + }, + }) + .await + } +} + +pub struct ProjectChannelAddBuilder { + pub args: workspace::channel::AddRemoveArgs, +} + +impl ProjectChannelAddBuilder { + /// Adds the specified channel + pub fn with_channel(mut self, name: impl Into) -> Self { + self.args + .channel + .push(NamedChannelOrUrl::from_str(&name.into()).unwrap()); + self + } + + pub fn with_priority(mut self, priority: Option) -> Self { + self.args.priority = priority; + self + } + + /// Alias to add a local channel. + pub fn with_local_channel(self, channel: impl AsRef) -> Self { + self.with_channel(Url::from_directory_path(channel).unwrap()) + } +} + +impl IntoFuture for ProjectChannelAddBuilder { + type Output = miette::Result<()>; + type IntoFuture = Pin + 'static>>; + + fn into_future(self) -> Self::IntoFuture { + workspace::channel::execute(workspace::channel::Args { + command: workspace::channel::Command::Add(self.args), + }) + .boxed_local() + } +} + +pub struct ProjectChannelRemoveBuilder { + pub manifest_path: Option, + pub args: workspace::channel::AddRemoveArgs, +} + +impl ProjectChannelRemoveBuilder { + /// Removes the specified channel + pub fn with_channel(mut self, name: impl Into) -> Self { + self.args + .channel + .push(NamedChannelOrUrl::from_str(&name.into()).unwrap()); + self + } + + /// Alias to Remove a local channel. + pub fn with_local_channel(self, channel: impl AsRef) -> Self { + self.with_channel(Url::from_directory_path(channel).unwrap()) + } +} + +impl IntoFuture for ProjectChannelRemoveBuilder { + type Output = miette::Result<()>; + type IntoFuture = Pin + 'static>>; + + fn into_future(self) -> Self::IntoFuture { + workspace::channel::execute(workspace::channel::Args { + command: workspace::channel::Command::Remove(self.args), + }) + .boxed_local() + } +} + +/// Contains the arguments to pass to [`install::execute()`]. Call `.await` to +/// call the CLI execute method and await the result at the same time. +pub struct InstallBuilder { + pub args: install::Args, +} + +impl InstallBuilder { + pub fn with_locked(mut self) -> Self { + self.args.lock_file_usage.locked = true; + self + } + pub fn with_frozen(mut self) -> Self { + self.args.lock_file_usage.frozen = true; + self + } + pub fn with_skipped(mut self, names: Vec) -> Self { + self.args.skip = Some(names); + self + } + pub fn with_skipped_with_deps(mut self, names: Vec) -> Self { + self.args.skip_with_deps = Some(names); + self + } + pub fn with_only_package(mut self, pkg: Vec) -> Self { + self.args.only = Some(pkg); + self + } +} + +impl IntoFuture for InstallBuilder { + type Output = miette::Result<()>; + type IntoFuture = Pin + 'static>>; + fn into_future(self) -> Self::IntoFuture { + install::execute(self.args).boxed_local() + } +} + +pub struct ProjectEnvironmentAddBuilder { + pub args: workspace::environment::AddArgs, + pub manifest_path: Option, +} + +impl ProjectEnvironmentAddBuilder { + pub fn with_feature(mut self, feature: impl Into) -> Self { + self.args + .features + .get_or_insert_with(Vec::new) + .push(feature.into()); + self + } + + pub fn with_no_default_features(mut self, no_default_features: bool) -> Self { + self.args.no_default_feature = no_default_features; + self + } + + pub fn force(mut self, force: bool) -> Self { + self.args.force = force; + self + } + + pub fn with_solve_group(mut self, solve_group: impl Into) -> Self { + self.args.solve_group = Some(solve_group.into()); + self + } +} + +impl IntoFuture for ProjectEnvironmentAddBuilder { + type Output = miette::Result<()>; + type IntoFuture = Pin + 'static>>; + fn into_future(self) -> Self::IntoFuture { + workspace::environment::execute(workspace::environment::Args { + workspace_config: WorkspaceConfig { + manifest_path: self.manifest_path, + ..Default::default() + }, + command: workspace::environment::Command::Add(self.args), + }) + .boxed_local() + } +} + +/// Contains the arguments to pass to [`update::execute()`]. Call `.await` to +/// call the CLI execute method and await the result at the same time. +pub struct UpdateBuilder { + pub args: update::Args, +} + +impl UpdateBuilder { + pub fn with_package(mut self, package: impl ToString) -> Self { + self.args + .specs + .packages + .get_or_insert_with(Vec::new) + .push(package.to_string()); + self + } + + pub fn with_environment(mut self, env: impl Into) -> Self { + self.args + .specs + .environments + .get_or_insert_with(Vec::new) + .push(env.into()); + self + } + + pub fn with_platform(mut self, platform: Platform) -> Self { + self.args + .specs + .platforms + .get_or_insert_with(Vec::new) + .push(platform); + self + } + + pub fn dry_run(mut self, dry_run: bool) -> Self { + self.args.dry_run = dry_run; + self + } + + pub fn json(mut self, json: bool) -> Self { + self.args.json = json; + self + } + + pub fn with_no_install(mut self, no_install: bool) -> Self { + self.args.no_install = no_install; + self + } +} + +impl IntoFuture for UpdateBuilder { + type Output = miette::Result<()>; + type IntoFuture = Pin + 'static>>; + fn into_future(self) -> Self::IntoFuture { + update::execute(self.args).boxed_local() + } +} + +/// Contains the arguments to pass to [`lock::execute()`]. Call `.await` to call +/// the CLI execute method and await the result at the same time. +pub struct LockBuilder { + pub args: lock::Args, +} + +impl IntoFuture for LockBuilder { + type Output = miette::Result<()>; + type IntoFuture = Pin + 'static>>; + + fn into_future(self) -> Self::IntoFuture { + lock::execute(self.args).boxed_local() + } +} + +/// Contains the arguments to pass to [`build::execute()`]. Call `.await` to call +/// the CLI execute method and await the result at the same time. +pub struct BuildBuilder { + pub args: build::Args, +} + +impl BuildBuilder { + /// Set the target platform for the build + pub fn with_target_platform(mut self, platform: Platform) -> Self { + self.args.target_platform = platform; + self + } + + /// Set the build platform for the build + pub fn with_build_platform(mut self, platform: Platform) -> Self { + self.args.build_platform = platform; + self + } + + /// Set the output directory for built artifacts + pub fn with_output_dir(mut self, output_dir: impl Into) -> Self { + self.args.output_dir = output_dir.into(); + self + } + + /// Set the build directory for incremental builds + pub fn with_build_dir(mut self, build_dir: impl Into) -> Self { + self.args.build_dir = Some(build_dir.into()); + self + } + + /// Set whether to clean the build directory before building + pub fn with_clean(mut self, clean: bool) -> Self { + self.args.clean = clean; + self + } + + /// Set the path to the package manifest or directory + pub fn with_path(mut self, path: impl Into) -> Self { + self.args.path = Some(path.into()); + self + } +} + +impl IntoFuture for BuildBuilder { + type Output = miette::Result<()>; + type IntoFuture = Pin + 'static>>; + + fn into_future(self) -> Self::IntoFuture { + build::execute(self.args).boxed_local() + } +} + +/// Contains the arguments to pass to [`global::execute()`]. Call `.await` to call +/// the CLI execute method and await the result at the same time. +pub struct GlobalInstallBuilder { + args: global::install::Args, + tmpdir: PathBuf, +} + +impl GlobalInstallBuilder { + /// Create a new GlobalInstallBuilder + pub fn new( + tmpdir: PathBuf, + backend_override: Option, + ) -> Self { + let mut args = global::install::Args::default(); + args.backend_override = backend_override; + Self { args, tmpdir } + } + + pub fn with_path(mut self, path: impl ToString) -> Self { + self.args.packages.path = Some(Into::::into(path.to_string())); + self + } + + pub fn with_force_reinstall(mut self, force_reinstall: bool) -> Self { + self.args.force_reinstall = force_reinstall; + self + } +} + +impl IntoFuture for GlobalInstallBuilder { + type Output = miette::Result<()>; + type IntoFuture = Pin + 'static>>; + + fn into_future(self) -> Self::IntoFuture { + let args = global::Args { + command: global::Command::Install(self.args), + }; + + temp_env::async_with_vars( + [ + ("PIXI_HOME", Some(self.tmpdir.clone())), + ("PIXI_CACHE_DIR", Some(self.tmpdir.clone())), + ], + async { global::execute(args).await }, + ) + .boxed_local() + } +} diff --git a/crates/pixi/tests/integration_rust/common/mod.rs b/crates/pixi/tests/integration_rust/common/mod.rs index 014dd7f237..6d07db5f46 100644 --- a/crates/pixi/tests/integration_rust/common/mod.rs +++ b/crates/pixi/tests/integration_rust/common/mod.rs @@ -1,844 +1,845 @@ -#![allow(dead_code)] - -pub mod builders; -pub mod client; -pub mod logging; -pub mod pypi_index; - -pub use pixi_test_utils::GitRepoFixture; - -use std::{ - ffi::OsString, - path::{Path, PathBuf}, - process::Output, - str::FromStr, -}; - -use builders::{LockBuilder, SearchBuilder}; -use indicatif::ProgressDrawTarget; -use miette::{Context, Diagnostic, IntoDiagnostic}; -use pixi_cli::LockFileUsageConfig; -use pixi_cli::cli_config::{ - ChannelsConfig, LockFileUpdateConfig, NoInstallConfig, WorkspaceConfig, -}; -use pixi_cli::{ - add, build, - init::{self, GitAttributes}, - install::Args, - lock, remove, run, search, - task::{self, AddArgs, AliasArgs}, - update, workspace, -}; -use pixi_consts::consts; -use pixi_core::{ - InstallFilter, UpdateLockFileOptions, Workspace, - lock_file::{ReinstallPackages, UpdateMode}, -}; -use pixi_manifest::{EnvironmentName, FeatureName}; -use pixi_progress::global_multi_progress; -use pixi_task::{ - ExecutableTask, RunOutput, SearchEnvironments, TaskExecutionError, TaskGraph, TaskGraphError, - TaskName, get_task_env, -}; -use rattler_conda_types::{MatchSpec, ParseStrictness::Lenient, Platform}; -use rattler_lock::{LockFile, LockedPackageRef, UrlOrPath}; -use tempfile::TempDir; -use thiserror::Error; - -use self::builders::{HasDependencyConfig, RemoveBuilder}; -use crate::common::builders::{ - AddBuilder, BuildBuilder, GlobalInstallBuilder, InitBuilder, InstallBuilder, - ProjectChannelAddBuilder, ProjectChannelRemoveBuilder, ProjectEnvironmentAddBuilder, - TaskAddBuilder, TaskAliasBuilder, UpdateBuilder, -}; - -const DEFAULT_PROJECT_CONFIG: &str = r#" -default-channels = ["https://prefix.dev/conda-forge"] - -[repodata-config."https://prefix.dev"] -disable-sharded = false -"#; - -/// Returns the path to the root of the workspace. -pub(crate) fn cargo_workspace_dir() -> &'static Path { - Path::new(env!("CARGO_WORKSPACE_DIR")) -} - -/// Returns the path to the `tests/data/workspaces` directory in the repository. -pub(crate) fn workspaces_dir() -> PathBuf { - cargo_workspace_dir().join("tests/data/workspaces") -} - -/// To control the pixi process -pub struct PixiControl { - /// The path to the project working file - tmpdir: TempDir, - /// Optional backend override for testing purposes - backend_override: Option, -} - -pub struct RunResult { - output: Output, -} - -/// Hides the progress bars for the tests -fn hide_progress_bars() { - global_multi_progress().set_draw_target(ProgressDrawTarget::hidden()); -} - -impl RunResult { - /// Was the output successful - pub fn success(&self) -> bool { - self.output.status.success() - } - - /// Get the output - pub fn stdout(&self) -> &str { - std::str::from_utf8(&self.output.stdout).expect("could not get output") - } -} - -/// MatchSpecs from an iterator -pub fn string_from_iter(iter: impl IntoIterator>) -> Vec { - iter.into_iter().map(|s| s.as_ref().to_string()).collect() -} - -pub trait LockFileExt { - /// Check if this package is contained in the lockfile - fn contains_conda_package(&self, environment: &str, platform: Platform, name: &str) -> bool; - fn contains_pypi_package(&self, environment: &str, platform: Platform, name: &str) -> bool; - /// Check if this matchspec is contained in the lockfile - fn contains_match_spec( - &self, - environment: &str, - platform: Platform, - match_spec: impl IntoMatchSpec, - ) -> bool; - - /// Check if the pep508 requirement is contained in the lockfile for this - /// platform - fn contains_pep508_requirement( - &self, - environment: &str, - platform: Platform, - requirement: pep508_rs::Requirement, - ) -> bool; - - fn get_pypi_package_version( - &self, - environment: &str, - platform: Platform, - package: &str, - ) -> Option; - - fn get_pypi_package_url( - &self, - environment: &str, - platform: Platform, - package: &str, - ) -> Option; - - fn get_pypi_package( - &self, - environment: &str, - platform: Platform, - package: &str, - ) -> Option; - - /// Check if a PyPI package is marked as editable in the lock file - fn is_pypi_package_editable( - &self, - environment: &str, - platform: Platform, - package: &str, - ) -> Option; -} - -impl LockFileExt for LockFile { - fn contains_conda_package(&self, environment: &str, platform: Platform, name: &str) -> bool { - let Some(env) = self.environment(environment) else { - return false; - }; - - env.packages(platform) - .into_iter() - .flatten() - .filter_map(LockedPackageRef::as_conda) - .any(|package| package.record().name.as_normalized() == name) - } - fn contains_pypi_package(&self, environment: &str, platform: Platform, name: &str) -> bool { - let Some(env) = self.environment(environment) else { - return false; - }; - - env.packages(platform) - .into_iter() - .flatten() - .filter_map(LockedPackageRef::as_pypi) - .any(|(data, _)| data.name.as_ref() == name) - } - - fn contains_match_spec( - &self, - environment: &str, - platform: Platform, - match_spec: impl IntoMatchSpec, - ) -> bool { - let match_spec = match_spec.into(); - let Some(env) = self.environment(environment) else { - return false; - }; - - env.packages(platform) - .into_iter() - .flatten() - .filter_map(LockedPackageRef::as_conda) - .any(move |p| p.satisfies(&match_spec)) - } - - fn contains_pep508_requirement( - &self, - environment: &str, - platform: Platform, - requirement: pep508_rs::Requirement, - ) -> bool { - let Some(env) = self.environment(environment) else { - eprintln!("environment not found: {environment}"); - return false; - }; - - env.packages(platform) - .into_iter() - .flatten() - .filter_map(LockedPackageRef::as_pypi) - .any(move |(data, _)| data.satisfies(&requirement)) - } - - fn get_pypi_package_version( - &self, - environment: &str, - platform: Platform, - package: &str, - ) -> Option { - self.environment(environment) - .and_then(|env| { - env.pypi_packages(platform).and_then(|mut packages| { - packages.find(|(data, _)| data.name.as_ref() == package) - }) - }) - .map(|(data, _)| data.version.to_string()) - } - - fn get_pypi_package( - &self, - environment: &str, - platform: Platform, - package: &str, - ) -> Option { - self.environment(environment).and_then(|env| { - env.packages(platform) - .and_then(|mut packages| packages.find(|p| p.name() == package)) - }) - } - - fn get_pypi_package_url( - &self, - environment: &str, - platform: Platform, - package: &str, - ) -> Option { - self.environment(environment) - .and_then(|env| { - env.packages(platform) - .and_then(|mut packages| packages.find(|p| p.name() == package)) - }) - .map(|p| p.location().clone()) - } - - fn is_pypi_package_editable( - &self, - environment: &str, - platform: Platform, - package: &str, - ) -> Option { - self.environment(environment) - .and_then(|env| { - env.pypi_packages(platform).and_then(|mut packages| { - packages.find(|(data, _)| data.name.as_ref() == package) - }) - }) - .map(|(data, _)| data.editable) - } -} - -impl PixiControl { - /// Create a new PixiControl instance - pub fn new() -> miette::Result { - let tempdir = tempfile::tempdir().into_diagnostic()?; - - // Add default project config - let pixi_path = tempdir.path().join(".pixi"); - fs_err::create_dir_all(&pixi_path).unwrap(); - fs_err::write(pixi_path.join("config.toml"), DEFAULT_PROJECT_CONFIG).unwrap(); - - // Hide the progress bars for the tests - // Otherwise the override the test output - hide_progress_bars(); - Ok(PixiControl { - tmpdir: tempdir, - backend_override: None, - }) - } - - /// Set a backend override for testing purposes. This allows injecting - /// custom build backends for testing build operations without needing - /// actual backend processes. - pub fn with_backend_override( - mut self, - backend_override: pixi_build_frontend::BackendOverride, - ) -> Self { - self.backend_override = Some(backend_override); - self - } - - /// Creates a new PixiControl instance from an existing manifest - pub fn from_manifest(manifest: &str) -> miette::Result { - let pixi = Self::new()?; - fs_err::write(pixi.manifest_path(), manifest) - .into_diagnostic() - .context("failed to write pixi.toml")?; - Ok(pixi) - } - - /// Creates a new PixiControl instance from an pyproject manifest - pub fn from_pyproject_manifest(pyproject_manifest: &str) -> miette::Result { - let pixi = Self::new()?; - fs_err::write(pixi.pyproject_manifest_path(), pyproject_manifest) - .into_diagnostic() - .context("failed to write pixi.toml")?; - Ok(pixi) - } - - /// Updates the complete manifest - pub fn update_manifest(&self, manifest: &str) -> miette::Result<()> { - fs_err::write(self.manifest_path(), manifest) - .into_diagnostic() - .context("failed to write pixi.toml")?; - Ok(()) - } - - /// Loads the workspace manifest and returns it. - pub fn workspace(&self) -> miette::Result { - let mut workspace = Workspace::from_path(&self.manifest_path()).into_diagnostic()?; - if let Some(backend_override) = &self.backend_override { - workspace = workspace.with_backend_override(backend_override.clone()); - } - Ok(workspace) - } - - /// Get the path to the workspace - pub fn workspace_path(&self) -> &Path { - self.tmpdir.path() - } - - /// Get path to default environment - pub fn default_env_path(&self) -> miette::Result { - let project = self.workspace()?; - let env = project.environment("default"); - let env = env.ok_or_else(|| miette::miette!("default environment not found"))?; - Ok(self.tmpdir.path().join(env.dir())) - } - - /// Get path to default environment - pub fn env_path(&self, env_name: &str) -> miette::Result { - let workspace = self.workspace()?; - let env = workspace.environment(env_name); - let env = env.ok_or_else(|| miette::miette!("{} environment not found", env_name))?; - Ok(self.tmpdir.path().join(env.dir())) - } - - pub fn manifest_path(&self) -> PathBuf { - // Either pixi.toml or pyproject.toml - if self - .workspace_path() - .join(consts::WORKSPACE_MANIFEST) - .exists() - { - self.workspace_path().join(consts::WORKSPACE_MANIFEST) - } else if self - .workspace_path() - .join(consts::PYPROJECT_MANIFEST) - .exists() - { - self.workspace_path().join(consts::PYPROJECT_MANIFEST) - } else { - self.workspace_path().join(consts::WORKSPACE_MANIFEST) - } - } - - pub(crate) fn pyproject_manifest_path(&self) -> PathBuf { - self.workspace_path().join(consts::PYPROJECT_MANIFEST) - } - - /// Get the manifest contents - pub fn manifest_contents(&self) -> miette::Result { - fs_err::read_to_string(self.manifest_path()) - .into_diagnostic() - .context("failed to read manifest") - } - - /// Initialize pixi project inside a temporary directory. Returns a - /// [`InitBuilder`]. To execute the command and await the result call - /// `.await` on the return value. - pub fn init(&self) -> InitBuilder { - InitBuilder { - no_fast_prefix: false, - args: init::Args { - path: self.workspace_path().to_path_buf(), - channels: None, - platforms: Vec::new(), - env_file: None, - format: None, - pyproject_toml: false, - scm: Some(GitAttributes::Github), - }, - } - } - - /// Initialize pixi project inside a temporary directory. Returns a - /// [`InitBuilder`]. To execute the command and await the result, call - /// `.await` on the return value. - pub fn init_with_platforms(&self, platforms: Vec) -> InitBuilder { - InitBuilder { - no_fast_prefix: false, - args: init::Args { - path: self.workspace_path().to_path_buf(), - channels: None, - platforms, - env_file: None, - format: None, - pyproject_toml: false, - scm: Some(GitAttributes::Github), - }, - } - } - - /// Add a dependency to the project. Returns an [`AddBuilder`]. - /// To execute the command and await the result, call `.await` on the return value. - pub fn add(&self, spec: &str) -> AddBuilder { - self.add_multiple(vec![spec]) - } - - /// Add a pypi dependency to the project. Returns an [`AddBuilder`]. - /// To execute the command and await the result, call `.await` on the return value. - pub fn add_pypi(&self, spec: &str) -> AddBuilder { - self.add_multiple(vec![spec]).set_pypi(true) - } - - /// Add dependencies to the project. Returns an [`AddBuilder`]. - /// To execute the command and await the result, call `.await` on the return value. - pub fn add_multiple(&self, specs: Vec<&str>) -> AddBuilder { - AddBuilder { - args: add::Args { - workspace_config: WorkspaceConfig { - manifest_path: Some(self.manifest_path()), - backend_override: self.backend_override.clone(), - }, - dependency_config: AddBuilder::dependency_config_with_specs(specs), - no_install_config: NoInstallConfig { no_install: true }, - lock_file_update_config: LockFileUpdateConfig { - no_lockfile_update: false, - lock_file_usage: LockFileUsageConfig::default(), - }, - config: Default::default(), - editable: false, - }, - } - } - - /// Search and return latest package. Returns an [`SearchBuilder`]. - /// the command and await the result call `.await` on the return value. - pub fn search(&self, name: String) -> SearchBuilder { - SearchBuilder { - args: search::Args { - package: name, - project_config: WorkspaceConfig { - manifest_path: Some(self.manifest_path()), - ..Default::default() - }, - platform: Platform::current(), - limit: None, - channels: ChannelsConfig::default(), - }, - } - } - - /// Remove dependencies from the project. Returns a [`RemoveBuilder`]. - pub fn remove(&self, spec: &str) -> RemoveBuilder { - RemoveBuilder { - args: remove::Args { - workspace_config: WorkspaceConfig { - manifest_path: Some(self.manifest_path()), - ..Default::default() - }, - dependency_config: AddBuilder::dependency_config_with_specs(vec![spec]), - no_install_config: NoInstallConfig { no_install: true }, - lock_file_update_config: LockFileUpdateConfig { - no_lockfile_update: false, - lock_file_usage: LockFileUsageConfig::default(), - }, - config: Default::default(), - }, - } - } - - /// Add a new channel to the project. - pub fn project_channel_add(&self) -> ProjectChannelAddBuilder { - ProjectChannelAddBuilder { - args: workspace::channel::AddRemoveArgs { - workspace_config: WorkspaceConfig { - manifest_path: Some(self.manifest_path()), - ..Default::default() - }, - channel: vec![], - no_install_config: NoInstallConfig { no_install: true }, - lock_file_update_config: LockFileUpdateConfig { - no_lockfile_update: false, - lock_file_usage: LockFileUsageConfig::default(), - }, - config: Default::default(), - feature: None, - priority: None, - prepend: false, - }, - } - } - - /// Remove a channel from the project. - pub fn project_channel_remove(&self) -> ProjectChannelRemoveBuilder { - ProjectChannelRemoveBuilder { - manifest_path: Some(self.manifest_path()), - args: workspace::channel::AddRemoveArgs { - workspace_config: WorkspaceConfig { - manifest_path: Some(self.manifest_path()), - ..Default::default() - }, - channel: vec![], - no_install_config: NoInstallConfig { no_install: true }, - lock_file_update_config: LockFileUpdateConfig { - no_lockfile_update: false, - lock_file_usage: LockFileUsageConfig::default(), - }, - config: Default::default(), - feature: None, - priority: None, - prepend: false, - }, - } - } - - pub fn project_environment_add(&self, name: EnvironmentName) -> ProjectEnvironmentAddBuilder { - ProjectEnvironmentAddBuilder { - manifest_path: Some(self.manifest_path()), - args: workspace::environment::AddArgs { - name, - features: None, - solve_group: None, - no_default_feature: false, - force: false, - }, - } - } - - /// Run a command - pub async fn run(&self, mut args: run::Args) -> miette::Result { - args.workspace_config.manifest_path = args - .workspace_config - .manifest_path - .or_else(|| Some(self.manifest_path())); - - // Load the project - let project = self.workspace()?; - - // Extract the passed in environment name. - let explicit_environment = args - .environment - .map(|n| EnvironmentName::from_str(n.as_str())) - .transpose()? - .map(|n| { - project - .environment(&n) - .ok_or_else(|| miette::miette!("unknown environment '{n}'")) - }) - .transpose()?; - - // Ensure the lock-file is up-to-date - let lock_file = project - .update_lock_file(UpdateLockFileOptions { - lock_file_usage: args.lock_and_install_config.lock_file_usage().unwrap(), - ..UpdateLockFileOptions::default() - }) - .await? - .0; - - // Create a task graph from the command line arguments. - let search_env = SearchEnvironments::from_opt_env( - &project, - explicit_environment.clone(), - explicit_environment - .as_ref() - .map(|e| e.best_platform()) - .or(Some(Platform::current())), - ); - let task_graph = TaskGraph::from_cmd_args(&project, &search_env, args.task, false) - .map_err(RunError::TaskGraphError)?; - - // Iterate over all tasks in the graph and execute them. - let mut task_env = None; - let mut result = RunOutput::default(); - for task_id in task_graph.topological_order() { - let task = ExecutableTask::from_task_graph(&task_graph, task_id); - - // Construct the task environment if not already created. - let task_env = match task_env.as_ref() { - None => { - lock_file - .prefix( - &task.run_environment, - UpdateMode::Revalidate, - &ReinstallPackages::default(), - &InstallFilter::default(), - ) - .await?; - let env = - get_task_env(&task.run_environment, args.clean_env, None, false, false) - .await?; - task_env.insert(env) - } - Some(task_env) => task_env, - }; - - let task_env = task_env - .iter() - .map(|(k, v)| (OsString::from(k), OsString::from(v))) - .collect(); - - let output = task.execute_with_pipes(&task_env, None).await?; - result.stdout.push_str(&output.stdout); - result.stderr.push_str(&output.stderr); - result.exit_code = output.exit_code; - if output.exit_code != 0 { - return Err(RunError::NonZeroExitCode(output.exit_code).into()); - } - } - - Ok(result) - } - - /// Returns a [`InstallBuilder`]. To execute the command and await the - /// result call `.await` on the return value. - pub fn install(&self) -> InstallBuilder { - InstallBuilder { - args: Args { - environment: None, - project_config: WorkspaceConfig { - manifest_path: Some(self.manifest_path()), - ..Default::default() - }, - lock_file_usage: LockFileUsageConfig { - frozen: false, - locked: false, - }, - config: Default::default(), - all: false, - skip: None, - skip_with_deps: None, - only: None, - }, - } - } - - /// Returns a [`GlobalInstallBuilder`]. - /// To execute the command and await the result, call `.await` on the return value. - pub fn global_install(&self) -> GlobalInstallBuilder { - GlobalInstallBuilder::new( - self.tmpdir.path().to_path_buf(), - self.backend_override.clone(), - ) - } - - /// Returns a [`UpdateBuilder]. To execute the command and await the result - /// call `.await` on the return value. - pub fn update(&self) -> UpdateBuilder { - UpdateBuilder { - args: update::Args { - config: Default::default(), - project_config: WorkspaceConfig { - manifest_path: Some(self.manifest_path()), - ..Default::default() - }, - no_install: true, - dry_run: false, - specs: Default::default(), - json: false, - }, - } - } - - /// Load the current lock-file. - /// - /// If you want to lock-file to be up-to-date with the project call - /// [`Self::update_lock_file`]. - pub async fn lock_file(&self) -> miette::Result { - let workspace = Workspace::from_path(&self.manifest_path())?; - workspace.load_lock_file().await?.into_lock_file() - } - - /// Load the current lock-file and makes sure that its up to date with the - /// project. - pub async fn update_lock_file(&self) -> miette::Result { - let project = self.workspace()?; - Ok(project - .update_lock_file(UpdateLockFileOptions::default()) - .await? - .0 - .into_lock_file()) - } - - /// Returns an [`LockBuilder`]. - /// To execute the command and await the result, call `.await` on the return value. - pub fn lock(&self) -> LockBuilder { - LockBuilder { - args: lock::Args { - workspace_config: WorkspaceConfig { - manifest_path: Some(self.manifest_path()), - ..Default::default() - }, - no_install_config: NoInstallConfig { no_install: false }, - check: false, - json: false, - }, - } - } - - /// Returns a [`BuildBuilder`]. To execute the command and await the result - /// call `.await` on the return value. - pub fn build(&self) -> BuildBuilder { - BuildBuilder { - args: build::Args { - backend_override: Default::default(), - config_cli: Default::default(), - lock_and_install_config: Default::default(), - target_platform: rattler_conda_types::Platform::current(), - build_platform: rattler_conda_types::Platform::current(), - output_dir: PathBuf::from("."), - build_dir: None, - clean: false, - path: Some(self.manifest_path()), - }, - } - } - - pub fn tasks(&self) -> TasksControl { - TasksControl { pixi: self } - } -} - -pub struct TasksControl<'a> { - /// Reference to the pixi control - pixi: &'a PixiControl, -} - -impl TasksControl<'_> { - /// Add a task - pub fn add( - &self, - name: TaskName, - platform: Option, - feature_name: FeatureName, - ) -> TaskAddBuilder { - TaskAddBuilder { - manifest_path: Some(self.pixi.manifest_path()), - args: AddArgs { - name, - commands: vec![], - depends_on: None, - platform, - feature: feature_name.non_default().map(str::to_owned), - cwd: None, - env: Default::default(), - description: None, - clean_env: false, - args: None, - }, - } - } - - /// Remove a task - pub async fn remove( - &self, - name: TaskName, - platform: Option, - feature_name: Option, - ) -> miette::Result<()> { - task::execute(task::Args { - workspace_config: WorkspaceConfig { - manifest_path: Some(self.pixi.manifest_path()), - ..Default::default() - }, - operation: task::Operation::Remove(task::RemoveArgs { - names: vec![name], - platform, - feature: feature_name, - }), - }) - .await - } - - /// Alias one or multiple tasks - pub fn alias(&self, name: TaskName, platform: Option) -> TaskAliasBuilder { - TaskAliasBuilder { - manifest_path: Some(self.pixi.manifest_path()), - args: AliasArgs { - platform, - alias: name, - depends_on: vec![], - description: None, - }, - } - } -} - -/// A helper trait to convert from different types into a [`MatchSpec`] to make -/// it simpler to use them in tests. -pub trait IntoMatchSpec { - fn into(self) -> MatchSpec; -} - -impl IntoMatchSpec for &str { - fn into(self) -> MatchSpec { - MatchSpec::from_str(self, Lenient).unwrap() - } -} - -impl IntoMatchSpec for String { - fn into(self) -> MatchSpec { - MatchSpec::from_str(&self, Lenient).unwrap() - } -} - -impl IntoMatchSpec for MatchSpec { - fn into(self) -> MatchSpec { - self - } -} - -#[derive(Error, Debug, Diagnostic)] -enum RunError { - #[error(transparent)] - TaskGraphError(#[from] TaskGraphError), - #[error(transparent)] - ExecutionError(#[from] TaskExecutionError), - #[error("the task executed with a non-zero exit code {0}")] - NonZeroExitCode(i32), -} +#![allow(dead_code)] + +pub mod builders; +pub mod client; +pub mod logging; +pub mod pypi_index; + +pub use pixi_test_utils::GitRepoFixture; + +use std::{ + ffi::OsString, + path::{Path, PathBuf}, + process::Output, + str::FromStr, +}; + +use builders::{LockBuilder, SearchBuilder}; +use indicatif::ProgressDrawTarget; +use miette::{Context, Diagnostic, IntoDiagnostic}; +use pixi_cli::LockFileUsageConfig; +use pixi_cli::cli_config::{ + ChannelsConfig, LockFileUpdateConfig, NoInstallConfig, WorkspaceConfig, +}; +use pixi_cli::{ + add, build, + init::{self, GitAttributes}, + install::Args, + lock, remove, run, search, + task::{self, AddArgs, AliasArgs}, + update, workspace, +}; +use pixi_consts::consts; +use pixi_core::{ + InstallFilter, UpdateLockFileOptions, Workspace, + lock_file::{ReinstallPackages, UpdateMode}, +}; +use pixi_manifest::{EnvironmentName, FeatureName}; +use pixi_progress::global_multi_progress; +use pixi_task::{ + ExecutableTask, RunOutput, SearchEnvironments, TaskExecutionError, TaskGraph, TaskGraphError, + TaskName, get_task_env, +}; +use rattler_conda_types::{MatchSpec, ParseStrictness::Lenient, Platform}; +use rattler_lock::{LockFile, LockedPackageRef, UrlOrPath}; +use tempfile::TempDir; +use thiserror::Error; + +use self::builders::{HasDependencyConfig, RemoveBuilder}; +use crate::common::builders::{ + AddBuilder, BuildBuilder, GlobalInstallBuilder, InitBuilder, InstallBuilder, + ProjectChannelAddBuilder, ProjectChannelRemoveBuilder, ProjectEnvironmentAddBuilder, + TaskAddBuilder, TaskAliasBuilder, UpdateBuilder, +}; + +const DEFAULT_PROJECT_CONFIG: &str = r#" +default-channels = ["https://prefix.dev/conda-forge"] + +[repodata-config."https://prefix.dev"] +disable-sharded = false +"#; + +/// Returns the path to the root of the workspace. +pub(crate) fn cargo_workspace_dir() -> &'static Path { + Path::new(env!("CARGO_WORKSPACE_DIR")) +} + +/// Returns the path to the `tests/data/workspaces` directory in the repository. +pub(crate) fn workspaces_dir() -> PathBuf { + cargo_workspace_dir().join("tests/data/workspaces") +} + +/// To control the pixi process +pub struct PixiControl { + /// The path to the project working file + tmpdir: TempDir, + /// Optional backend override for testing purposes + backend_override: Option, +} + +pub struct RunResult { + output: Output, +} + +/// Hides the progress bars for the tests +fn hide_progress_bars() { + global_multi_progress().set_draw_target(ProgressDrawTarget::hidden()); +} + +impl RunResult { + /// Was the output successful + pub fn success(&self) -> bool { + self.output.status.success() + } + + /// Get the output + pub fn stdout(&self) -> &str { + std::str::from_utf8(&self.output.stdout).expect("could not get output") + } +} + +/// MatchSpecs from an iterator +pub fn string_from_iter(iter: impl IntoIterator>) -> Vec { + iter.into_iter().map(|s| s.as_ref().to_string()).collect() +} + +pub trait LockFileExt { + /// Check if this package is contained in the lockfile + fn contains_conda_package(&self, environment: &str, platform: Platform, name: &str) -> bool; + fn contains_pypi_package(&self, environment: &str, platform: Platform, name: &str) -> bool; + /// Check if this matchspec is contained in the lockfile + fn contains_match_spec( + &self, + environment: &str, + platform: Platform, + match_spec: impl IntoMatchSpec, + ) -> bool; + + /// Check if the pep508 requirement is contained in the lockfile for this + /// platform + fn contains_pep508_requirement( + &self, + environment: &str, + platform: Platform, + requirement: pep508_rs::Requirement, + ) -> bool; + + fn get_pypi_package_version( + &self, + environment: &str, + platform: Platform, + package: &str, + ) -> Option; + + fn get_pypi_package_url( + &self, + environment: &str, + platform: Platform, + package: &str, + ) -> Option; + + fn get_pypi_package( + &self, + environment: &str, + platform: Platform, + package: &str, + ) -> Option; + + /// Check if a PyPI package is marked as editable in the lock file + fn is_pypi_package_editable( + &self, + environment: &str, + platform: Platform, + package: &str, + ) -> Option; +} + +impl LockFileExt for LockFile { + fn contains_conda_package(&self, environment: &str, platform: Platform, name: &str) -> bool { + let Some(env) = self.environment(environment) else { + return false; + }; + + env.packages(platform) + .into_iter() + .flatten() + .filter_map(LockedPackageRef::as_conda) + .any(|package| package.record().name.as_normalized() == name) + } + fn contains_pypi_package(&self, environment: &str, platform: Platform, name: &str) -> bool { + let Some(env) = self.environment(environment) else { + return false; + }; + + env.packages(platform) + .into_iter() + .flatten() + .filter_map(LockedPackageRef::as_pypi) + .any(|(data, _)| data.name.as_ref() == name) + } + + fn contains_match_spec( + &self, + environment: &str, + platform: Platform, + match_spec: impl IntoMatchSpec, + ) -> bool { + let match_spec = match_spec.into(); + let Some(env) = self.environment(environment) else { + return false; + }; + + env.packages(platform) + .into_iter() + .flatten() + .filter_map(LockedPackageRef::as_conda) + .any(move |p| p.satisfies(&match_spec)) + } + + fn contains_pep508_requirement( + &self, + environment: &str, + platform: Platform, + requirement: pep508_rs::Requirement, + ) -> bool { + let Some(env) = self.environment(environment) else { + eprintln!("environment not found: {environment}"); + return false; + }; + + env.packages(platform) + .into_iter() + .flatten() + .filter_map(LockedPackageRef::as_pypi) + .any(move |(data, _)| data.satisfies(&requirement)) + } + + fn get_pypi_package_version( + &self, + environment: &str, + platform: Platform, + package: &str, + ) -> Option { + self.environment(environment) + .and_then(|env| { + env.pypi_packages(platform).and_then(|mut packages| { + packages.find(|(data, _)| data.name.as_ref() == package) + }) + }) + .map(|(data, _)| data.version.to_string()) + } + + fn get_pypi_package( + &self, + environment: &str, + platform: Platform, + package: &str, + ) -> Option { + self.environment(environment).and_then(|env| { + env.packages(platform) + .and_then(|mut packages| packages.find(|p| p.name() == package)) + }) + } + + fn get_pypi_package_url( + &self, + environment: &str, + platform: Platform, + package: &str, + ) -> Option { + self.environment(environment) + .and_then(|env| { + env.packages(platform) + .and_then(|mut packages| packages.find(|p| p.name() == package)) + }) + .map(|p| p.location().clone()) + } + + fn is_pypi_package_editable( + &self, + environment: &str, + platform: Platform, + package: &str, + ) -> Option { + self.environment(environment) + .and_then(|env| { + env.pypi_packages(platform).and_then(|mut packages| { + packages.find(|(data, _)| data.name.as_ref() == package) + }) + }) + .map(|(data, _)| data.editable) + } +} + +impl PixiControl { + /// Create a new PixiControl instance + pub fn new() -> miette::Result { + let tempdir = tempfile::tempdir().into_diagnostic()?; + + // Add default project config + let pixi_path = tempdir.path().join(".pixi"); + fs_err::create_dir_all(&pixi_path).unwrap(); + fs_err::write(pixi_path.join("config.toml"), DEFAULT_PROJECT_CONFIG).unwrap(); + + // Hide the progress bars for the tests + // Otherwise the override the test output + hide_progress_bars(); + Ok(PixiControl { + tmpdir: tempdir, + backend_override: None, + }) + } + + /// Set a backend override for testing purposes. This allows injecting + /// custom build backends for testing build operations without needing + /// actual backend processes. + pub fn with_backend_override( + mut self, + backend_override: pixi_build_frontend::BackendOverride, + ) -> Self { + self.backend_override = Some(backend_override); + self + } + + /// Creates a new PixiControl instance from an existing manifest + pub fn from_manifest(manifest: &str) -> miette::Result { + let pixi = Self::new()?; + fs_err::write(pixi.manifest_path(), manifest) + .into_diagnostic() + .context("failed to write pixi.toml")?; + Ok(pixi) + } + + /// Creates a new PixiControl instance from an pyproject manifest + pub fn from_pyproject_manifest(pyproject_manifest: &str) -> miette::Result { + let pixi = Self::new()?; + fs_err::write(pixi.pyproject_manifest_path(), pyproject_manifest) + .into_diagnostic() + .context("failed to write pixi.toml")?; + Ok(pixi) + } + + /// Updates the complete manifest + pub fn update_manifest(&self, manifest: &str) -> miette::Result<()> { + fs_err::write(self.manifest_path(), manifest) + .into_diagnostic() + .context("failed to write pixi.toml")?; + Ok(()) + } + + /// Loads the workspace manifest and returns it. + pub fn workspace(&self) -> miette::Result { + let mut workspace = Workspace::from_path(&self.manifest_path()).into_diagnostic()?; + if let Some(backend_override) = &self.backend_override { + workspace = workspace.with_backend_override(backend_override.clone()); + } + Ok(workspace) + } + + /// Get the path to the workspace + pub fn workspace_path(&self) -> &Path { + self.tmpdir.path() + } + + /// Get path to default environment + pub fn default_env_path(&self) -> miette::Result { + let project = self.workspace()?; + let env = project.environment("default"); + let env = env.ok_or_else(|| miette::miette!("default environment not found"))?; + Ok(self.tmpdir.path().join(env.dir())) + } + + /// Get path to default environment + pub fn env_path(&self, env_name: &str) -> miette::Result { + let workspace = self.workspace()?; + let env = workspace.environment(env_name); + let env = env.ok_or_else(|| miette::miette!("{} environment not found", env_name))?; + Ok(self.tmpdir.path().join(env.dir())) + } + + pub fn manifest_path(&self) -> PathBuf { + // Either pixi.toml or pyproject.toml + if self + .workspace_path() + .join(consts::WORKSPACE_MANIFEST) + .exists() + { + self.workspace_path().join(consts::WORKSPACE_MANIFEST) + } else if self + .workspace_path() + .join(consts::PYPROJECT_MANIFEST) + .exists() + { + self.workspace_path().join(consts::PYPROJECT_MANIFEST) + } else { + self.workspace_path().join(consts::WORKSPACE_MANIFEST) + } + } + + pub(crate) fn pyproject_manifest_path(&self) -> PathBuf { + self.workspace_path().join(consts::PYPROJECT_MANIFEST) + } + + /// Get the manifest contents + pub fn manifest_contents(&self) -> miette::Result { + fs_err::read_to_string(self.manifest_path()) + .into_diagnostic() + .context("failed to read manifest") + } + + /// Initialize pixi project inside a temporary directory. Returns a + /// [`InitBuilder`]. To execute the command and await the result call + /// `.await` on the return value. + pub fn init(&self) -> InitBuilder { + InitBuilder { + no_fast_prefix: false, + args: init::Args { + path: self.workspace_path().to_path_buf(), + channels: None, + platforms: Vec::new(), + env_file: None, + format: None, + pyproject_toml: false, + scm: Some(GitAttributes::Github), + }, + } + } + + /// Initialize pixi project inside a temporary directory. Returns a + /// [`InitBuilder`]. To execute the command and await the result, call + /// `.await` on the return value. + pub fn init_with_platforms(&self, platforms: Vec) -> InitBuilder { + InitBuilder { + no_fast_prefix: false, + args: init::Args { + path: self.workspace_path().to_path_buf(), + channels: None, + platforms, + env_file: None, + format: None, + pyproject_toml: false, + scm: Some(GitAttributes::Github), + }, + } + } + + /// Add a dependency to the project. Returns an [`AddBuilder`]. + /// To execute the command and await the result, call `.await` on the return value. + pub fn add(&self, spec: &str) -> AddBuilder { + self.add_multiple(vec![spec]) + } + + /// Add a pypi dependency to the project. Returns an [`AddBuilder`]. + /// To execute the command and await the result, call `.await` on the return value. + pub fn add_pypi(&self, spec: &str) -> AddBuilder { + self.add_multiple(vec![spec]).set_pypi(true) + } + + /// Add dependencies to the project. Returns an [`AddBuilder`]. + /// To execute the command and await the result, call `.await` on the return value. + pub fn add_multiple(&self, specs: Vec<&str>) -> AddBuilder { + AddBuilder { + args: add::Args { + workspace_config: WorkspaceConfig { + manifest_path: Some(self.manifest_path()), + backend_override: self.backend_override.clone(), + }, + dependency_config: AddBuilder::dependency_config_with_specs(specs), + no_install_config: NoInstallConfig { no_install: true }, + lock_file_update_config: LockFileUpdateConfig { + no_lockfile_update: false, + lock_file_usage: LockFileUsageConfig::default(), + }, + config: Default::default(), + editable: false, + index: None, + }, + } + } + + /// Search and return latest package. Returns an [`SearchBuilder`]. + /// the command and await the result call `.await` on the return value. + pub fn search(&self, name: String) -> SearchBuilder { + SearchBuilder { + args: search::Args { + package: name, + project_config: WorkspaceConfig { + manifest_path: Some(self.manifest_path()), + ..Default::default() + }, + platform: Platform::current(), + limit: None, + channels: ChannelsConfig::default(), + }, + } + } + + /// Remove dependencies from the project. Returns a [`RemoveBuilder`]. + pub fn remove(&self, spec: &str) -> RemoveBuilder { + RemoveBuilder { + args: remove::Args { + workspace_config: WorkspaceConfig { + manifest_path: Some(self.manifest_path()), + ..Default::default() + }, + dependency_config: AddBuilder::dependency_config_with_specs(vec![spec]), + no_install_config: NoInstallConfig { no_install: true }, + lock_file_update_config: LockFileUpdateConfig { + no_lockfile_update: false, + lock_file_usage: LockFileUsageConfig::default(), + }, + config: Default::default(), + }, + } + } + + /// Add a new channel to the project. + pub fn project_channel_add(&self) -> ProjectChannelAddBuilder { + ProjectChannelAddBuilder { + args: workspace::channel::AddRemoveArgs { + workspace_config: WorkspaceConfig { + manifest_path: Some(self.manifest_path()), + ..Default::default() + }, + channel: vec![], + no_install_config: NoInstallConfig { no_install: true }, + lock_file_update_config: LockFileUpdateConfig { + no_lockfile_update: false, + lock_file_usage: LockFileUsageConfig::default(), + }, + config: Default::default(), + feature: None, + priority: None, + prepend: false, + }, + } + } + + /// Remove a channel from the project. + pub fn project_channel_remove(&self) -> ProjectChannelRemoveBuilder { + ProjectChannelRemoveBuilder { + manifest_path: Some(self.manifest_path()), + args: workspace::channel::AddRemoveArgs { + workspace_config: WorkspaceConfig { + manifest_path: Some(self.manifest_path()), + ..Default::default() + }, + channel: vec![], + no_install_config: NoInstallConfig { no_install: true }, + lock_file_update_config: LockFileUpdateConfig { + no_lockfile_update: false, + lock_file_usage: LockFileUsageConfig::default(), + }, + config: Default::default(), + feature: None, + priority: None, + prepend: false, + }, + } + } + + pub fn project_environment_add(&self, name: EnvironmentName) -> ProjectEnvironmentAddBuilder { + ProjectEnvironmentAddBuilder { + manifest_path: Some(self.manifest_path()), + args: workspace::environment::AddArgs { + name, + features: None, + solve_group: None, + no_default_feature: false, + force: false, + }, + } + } + + /// Run a command + pub async fn run(&self, mut args: run::Args) -> miette::Result { + args.workspace_config.manifest_path = args + .workspace_config + .manifest_path + .or_else(|| Some(self.manifest_path())); + + // Load the project + let project = self.workspace()?; + + // Extract the passed in environment name. + let explicit_environment = args + .environment + .map(|n| EnvironmentName::from_str(n.as_str())) + .transpose()? + .map(|n| { + project + .environment(&n) + .ok_or_else(|| miette::miette!("unknown environment '{n}'")) + }) + .transpose()?; + + // Ensure the lock-file is up-to-date + let lock_file = project + .update_lock_file(UpdateLockFileOptions { + lock_file_usage: args.lock_and_install_config.lock_file_usage().unwrap(), + ..UpdateLockFileOptions::default() + }) + .await? + .0; + + // Create a task graph from the command line arguments. + let search_env = SearchEnvironments::from_opt_env( + &project, + explicit_environment.clone(), + explicit_environment + .as_ref() + .map(|e| e.best_platform()) + .or(Some(Platform::current())), + ); + let task_graph = TaskGraph::from_cmd_args(&project, &search_env, args.task, false) + .map_err(RunError::TaskGraphError)?; + + // Iterate over all tasks in the graph and execute them. + let mut task_env = None; + let mut result = RunOutput::default(); + for task_id in task_graph.topological_order() { + let task = ExecutableTask::from_task_graph(&task_graph, task_id); + + // Construct the task environment if not already created. + let task_env = match task_env.as_ref() { + None => { + lock_file + .prefix( + &task.run_environment, + UpdateMode::Revalidate, + &ReinstallPackages::default(), + &InstallFilter::default(), + ) + .await?; + let env = + get_task_env(&task.run_environment, args.clean_env, None, false, false) + .await?; + task_env.insert(env) + } + Some(task_env) => task_env, + }; + + let task_env = task_env + .iter() + .map(|(k, v)| (OsString::from(k), OsString::from(v))) + .collect(); + + let output = task.execute_with_pipes(&task_env, None).await?; + result.stdout.push_str(&output.stdout); + result.stderr.push_str(&output.stderr); + result.exit_code = output.exit_code; + if output.exit_code != 0 { + return Err(RunError::NonZeroExitCode(output.exit_code).into()); + } + } + + Ok(result) + } + + /// Returns a [`InstallBuilder`]. To execute the command and await the + /// result call `.await` on the return value. + pub fn install(&self) -> InstallBuilder { + InstallBuilder { + args: Args { + environment: None, + project_config: WorkspaceConfig { + manifest_path: Some(self.manifest_path()), + ..Default::default() + }, + lock_file_usage: LockFileUsageConfig { + frozen: false, + locked: false, + }, + config: Default::default(), + all: false, + skip: None, + skip_with_deps: None, + only: None, + }, + } + } + + /// Returns a [`GlobalInstallBuilder`]. + /// To execute the command and await the result, call `.await` on the return value. + pub fn global_install(&self) -> GlobalInstallBuilder { + GlobalInstallBuilder::new( + self.tmpdir.path().to_path_buf(), + self.backend_override.clone(), + ) + } + + /// Returns a [`UpdateBuilder]. To execute the command and await the result + /// call `.await` on the return value. + pub fn update(&self) -> UpdateBuilder { + UpdateBuilder { + args: update::Args { + config: Default::default(), + project_config: WorkspaceConfig { + manifest_path: Some(self.manifest_path()), + ..Default::default() + }, + no_install: true, + dry_run: false, + specs: Default::default(), + json: false, + }, + } + } + + /// Load the current lock-file. + /// + /// If you want to lock-file to be up-to-date with the project call + /// [`Self::update_lock_file`]. + pub async fn lock_file(&self) -> miette::Result { + let workspace = Workspace::from_path(&self.manifest_path())?; + workspace.load_lock_file().await?.into_lock_file() + } + + /// Load the current lock-file and makes sure that its up to date with the + /// project. + pub async fn update_lock_file(&self) -> miette::Result { + let project = self.workspace()?; + Ok(project + .update_lock_file(UpdateLockFileOptions::default()) + .await? + .0 + .into_lock_file()) + } + + /// Returns an [`LockBuilder`]. + /// To execute the command and await the result, call `.await` on the return value. + pub fn lock(&self) -> LockBuilder { + LockBuilder { + args: lock::Args { + workspace_config: WorkspaceConfig { + manifest_path: Some(self.manifest_path()), + ..Default::default() + }, + no_install_config: NoInstallConfig { no_install: false }, + check: false, + json: false, + }, + } + } + + /// Returns a [`BuildBuilder`]. To execute the command and await the result + /// call `.await` on the return value. + pub fn build(&self) -> BuildBuilder { + BuildBuilder { + args: build::Args { + backend_override: Default::default(), + config_cli: Default::default(), + lock_and_install_config: Default::default(), + target_platform: rattler_conda_types::Platform::current(), + build_platform: rattler_conda_types::Platform::current(), + output_dir: PathBuf::from("."), + build_dir: None, + clean: false, + path: Some(self.manifest_path()), + }, + } + } + + pub fn tasks(&self) -> TasksControl { + TasksControl { pixi: self } + } +} + +pub struct TasksControl<'a> { + /// Reference to the pixi control + pixi: &'a PixiControl, +} + +impl TasksControl<'_> { + /// Add a task + pub fn add( + &self, + name: TaskName, + platform: Option, + feature_name: FeatureName, + ) -> TaskAddBuilder { + TaskAddBuilder { + manifest_path: Some(self.pixi.manifest_path()), + args: AddArgs { + name, + commands: vec![], + depends_on: None, + platform, + feature: feature_name.non_default().map(str::to_owned), + cwd: None, + env: Default::default(), + description: None, + clean_env: false, + args: None, + }, + } + } + + /// Remove a task + pub async fn remove( + &self, + name: TaskName, + platform: Option, + feature_name: Option, + ) -> miette::Result<()> { + task::execute(task::Args { + workspace_config: WorkspaceConfig { + manifest_path: Some(self.pixi.manifest_path()), + ..Default::default() + }, + operation: task::Operation::Remove(task::RemoveArgs { + names: vec![name], + platform, + feature: feature_name, + }), + }) + .await + } + + /// Alias one or multiple tasks + pub fn alias(&self, name: TaskName, platform: Option) -> TaskAliasBuilder { + TaskAliasBuilder { + manifest_path: Some(self.pixi.manifest_path()), + args: AliasArgs { + platform, + alias: name, + depends_on: vec![], + description: None, + }, + } + } +} + +/// A helper trait to convert from different types into a [`MatchSpec`] to make +/// it simpler to use them in tests. +pub trait IntoMatchSpec { + fn into(self) -> MatchSpec; +} + +impl IntoMatchSpec for &str { + fn into(self) -> MatchSpec { + MatchSpec::from_str(self, Lenient).unwrap() + } +} + +impl IntoMatchSpec for String { + fn into(self) -> MatchSpec { + MatchSpec::from_str(&self, Lenient).unwrap() + } +} + +impl IntoMatchSpec for MatchSpec { + fn into(self) -> MatchSpec { + self + } +} + +#[derive(Error, Debug, Diagnostic)] +enum RunError { + #[error(transparent)] + TaskGraphError(#[from] TaskGraphError), + #[error(transparent)] + ExecutionError(#[from] TaskExecutionError), + #[error("the task executed with a non-zero exit code {0}")] + NonZeroExitCode(i32), +} diff --git a/crates/pixi_cli/src/add.rs b/crates/pixi_cli/src/add.rs index e3daa084f4..c39adc16d5 100644 --- a/crates/pixi_cli/src/add.rs +++ b/crates/pixi_cli/src/add.rs @@ -1,10 +1,13 @@ use clap::Parser; +use miette::{IntoDiagnostic, WrapErr}; use pixi_api::{ WorkspaceContext, workspace::{DependencyOptions, GitOptions}, }; use pixi_config::ConfigCli; use pixi_core::{DependencyType, WorkspaceLocator}; +use pixi_pypi_spec::PixiPypiSpec; +use url::Url; use crate::{ cli_config::{DependencyConfig, LockFileUpdateConfig, NoInstallConfig, WorkspaceConfig}, @@ -92,6 +95,10 @@ pub struct Args { /// Whether the pypi requirement should be editable #[arg(long, requires = "pypi")] pub editable: bool, + + /// The URL of the PyPI index to use for this dependency + #[arg(long, requires = "pypi")] + pub index: Option, } impl TryFrom<&Args> for DependencyOptions { @@ -158,6 +165,22 @@ pub async fn execute(args: Args) -> miette::Result<()> { .await? } DependencyType::PypiDependency => { + // Parse the index URL if provided + let index_url = args + .index + .as_ref() + .map(|url_str| Url::parse(url_str)) + .transpose() + .into_diagnostic() + .wrap_err("Failed to parse index URL")?; + + // Create PixiPypiSpec if index is provided + let pixi_spec = index_url.map(|url| PixiPypiSpec::Version { + version: pixi_pypi_spec::VersionOrStar::Star, + extras: Vec::new(), + index: Some(url), + }); + let pypi_deps = match args .dependency_config .vcs_pep508_requirements(&workspace) @@ -165,13 +188,13 @@ pub async fn execute(args: Args) -> miette::Result<()> { { Some(vcs_reqs) => vcs_reqs .into_iter() - .map(|(name, req)| (name, (req, None, None))) + .map(|(name, req)| (name, (req, pixi_spec.clone(), None))) .collect(), None => args .dependency_config .pypi_deps(&workspace)? .into_iter() - .map(|(name, req)| (name, (req, None, None))) + .map(|(name, req)| (name, (req, pixi_spec.clone(), None))) .collect(), };