diff --git a/src/cli/add.rs b/src/cli/add.rs index 15eaf772d..e3b3a1288 100644 --- a/src/cli/add.rs +++ b/src/cli/add.rs @@ -5,7 +5,7 @@ use crate::{ }; use clap::Parser; use indexmap::IndexMap; -use itertools::Itertools; +use itertools::{Either, Itertools}; use miette::{IntoDiagnostic, WrapErr}; use rattler_conda_types::{ @@ -255,11 +255,11 @@ pub async fn add_conda_specs_to_project( let mut package_versions = HashMap::>::new(); let platforms = if specs_platforms.is_empty() { - project.platforms() + Either::Left(project.platforms().into_iter()) } else { - specs_platforms - } - .to_vec(); + Either::Right(specs_platforms.iter().copied()) + }; + for platform in platforms { // TODO: `build` and `host` has to be separated when we have separated environments for them. // While we combine them on install we should also do that on getting the best version. diff --git a/src/cli/global/install.rs b/src/cli/global/install.rs index fa6c91be7..e47f0afa4 100644 --- a/src/cli/global/install.rs +++ b/src/cli/global/install.rs @@ -338,7 +338,7 @@ pub async fn execute(args: Args) -> miette::Result<()> { let platform = Platform::current(); // Fetch sparse repodata - let platform_sparse_repodata = fetch_sparse_repodata(&channels, &[platform]).await?; + let platform_sparse_repodata = fetch_sparse_repodata(&channels, [platform]).await?; let available_packages = SparseRepoData::load_records_recursive( platform_sparse_repodata.iter(), diff --git a/src/cli/info.rs b/src/cli/info.rs index 6cc0ed91e..1c0f00341 100644 --- a/src/cli/info.rs +++ b/src/cli/info.rs @@ -204,7 +204,7 @@ pub async fn execute(args: Args) -> miette::Result<()> { package_count: dependency_count(&p).ok(), environment_size, last_updated: last_updated(p.lock_file_path()).ok(), - platforms: p.platforms().to_vec(), + platforms: p.platforms().into_iter().collect(), }); let virtual_packages = VirtualPackage::current() diff --git a/src/cli/project/channel/list.rs b/src/cli/project/channel/list.rs index aa5ea00d0..5f78fdc64 100644 --- a/src/cli/project/channel/list.rs +++ b/src/cli/project/channel/list.rs @@ -9,7 +9,7 @@ pub struct Args { } pub async fn execute(project: Project, args: Args) -> miette::Result<()> { - project.channels().iter().for_each(|channel| { + project.channels().into_iter().for_each(|channel| { if args.urls { // Print the channel's url println!("{}", channel.base_url()); diff --git a/src/cli/search.rs b/src/cli/search.rs index 3c8284c5f..9cac0a21d 100644 --- a/src/cli/search.rs +++ b/src/cli/search.rs @@ -1,3 +1,4 @@ +use std::borrow::Cow; use std::{cmp::Ordering, path::PathBuf}; use clap::Parser; @@ -81,22 +82,25 @@ pub async fn execute(args: Args) -> miette::Result<()> { let channel_config = ChannelConfig::default(); - let channels = match (args.channel, project) { + let channels = match (args.channel, project.as_ref()) { // if user passes channels through the channel flag (Some(c), _) => c .iter() .map(|c| Channel::from_str(c, &channel_config)) - .collect::, _>>() + .map_ok(Cow::Owned) + .collect::, _>>() .into_diagnostic()?, // if user doesn't pass channels and we are in a project - (None, Some(p)) => p.channels().to_owned(), + (None, Some(p)) => p.channels().into_iter().map(Cow::Borrowed).collect(), // if user doesn't pass channels and we are not in project - (None, None) => vec![Channel::from_str("conda-forge", &channel_config).into_diagnostic()?], + (None, None) => vec![Cow::Owned( + Channel::from_str("conda-forge", &channel_config).into_diagnostic()?, + )], }; let package_name_filter = args.package; - let platforms = [Platform::current()]; - let repo_data = fetch_sparse_repodata(&channels, &platforms).await?; + let repo_data = + fetch_sparse_repodata(channels.iter().map(AsRef::as_ref), [Platform::current()]).await?; // When package name filter contains * (wildcard), it will search and display a list of packages matching this filter if package_name_filter.contains('*') { diff --git a/src/cli/task.rs b/src/cli/task.rs index d775ca81c..fc59fc2b8 100644 --- a/src/cli/task.rs +++ b/src/cli/task.rs @@ -178,19 +178,22 @@ pub fn execute(args: Args) -> miette::Result<()> { } // Check if task has dependencies - let depends_on = project.task_names_depending_on(name); - if !depends_on.is_empty() && !args.names.contains(name) { - eprintln!( - "{}: {}", - console::style("Warning, the following task/s depend on this task") - .yellow(), - console::style(depends_on.iter().to_owned().join(", ")).bold() - ); - eprintln!( - "{}", - console::style("Be sure to modify these after the removal\n").yellow() - ); - } + // TODO: Make this properly work by inspecting which actual tasks depend on the task + // we just removed taking into account environments and features. + // let depends_on = project.task_names_depending_on(name); + // if !depends_on.is_empty() && !args.names.contains(name) { + // eprintln!( + // "{}: {}", + // console::style("Warning, the following task/s depend on this task") + // .yellow(), + // console::style(depends_on.iter().to_owned().join(", ")).bold() + // ); + // eprintln!( + // "{}", + // console::style("Be sure to modify these after the removal\n").yellow() + // ); + // } + // Safe to remove to_remove.push((name, args.platform)); } @@ -220,7 +223,10 @@ pub fn execute(args: Args) -> miette::Result<()> { ); } Operation::List(args) => { - let tasks = project.task_names(Some(Platform::current())); + let tasks = project + .tasks(Some(Platform::current())) + .into_keys() + .collect_vec(); if tasks.is_empty() { eprintln!("No tasks found",); } else { diff --git a/src/consts.rs b/src/consts.rs index 263e1ce5e..45ef441e6 100644 --- a/src/consts.rs +++ b/src/consts.rs @@ -4,3 +4,5 @@ pub const PIXI_DIR: &str = ".pixi"; pub const PREFIX_FILE_NAME: &str = "prefix"; pub const ENVIRONMENT_DIR: &str = "env"; pub const PYPI_DEPENDENCIES: &str = "pypi-dependencies"; + +pub const DEFAULT_ENVIRONMENT_NAME: &str = "default"; diff --git a/src/lock_file/mod.rs b/src/lock_file/mod.rs index de544ded4..d064578f0 100644 --- a/src/lock_file/mod.rs +++ b/src/lock_file/mod.rs @@ -48,12 +48,11 @@ fn main_progress_bar(num_bars: u64, message: &'static str) -> ProgressBar { top_level_progress } -fn platform_solve_bars(platforms: &[Platform]) -> Vec { +fn platform_solve_bars(platforms: impl IntoIterator) -> Vec { platforms - .iter() + .into_iter() .map(|platform| { - let pb = - progress::global_multi_progress().add(ProgressBar::new(platforms.len() as u64)); + let pb = progress::global_multi_progress().add(ProgressBar::new(0)); pb.set_style( indicatif::ProgressStyle::with_template(&format!( " {:<9} ..", @@ -87,12 +86,12 @@ pub async fn update_lock_file_conda( let _top_level_progress = main_progress_bar(platforms.len() as u64, "resolving conda dependencies"); // Create progress bars for each platform - let solve_bars = platform_solve_bars(platforms); + let solve_bars = platform_solve_bars(platforms.iter().copied()); // Construct a conda lock file let channels = project .channels() - .iter() + .into_iter() .map(|channel| rattler_lock::Channel::from(channel.base_url().to_string())); let result: miette::Result> = @@ -162,7 +161,7 @@ pub async fn update_lock_file_for_pypi( let platforms = project.platforms(); let _top_level_progress = main_progress_bar(platforms.len() as u64, "resolving pypi dependencies"); - let solve_bars = platform_solve_bars(platforms); + let solve_bars = platform_solve_bars(platforms.iter().copied()); let records = platforms .iter() @@ -216,7 +215,7 @@ pub async fn update_lock_file_for_pypi( let channels = project .channels() - .iter() + .into_iter() .map(|channel| rattler_lock::Channel::from(channel.base_url().to_string())); let mut builder = LockFileBuilder::new(channels, platforms.iter().cloned(), vec![]); for locked_packages in result? { diff --git a/src/lock_file/satisfiability.rs b/src/lock_file/satisfiability.rs index 60aa7391f..3b1778923 100644 --- a/src/lock_file/satisfiability.rs +++ b/src/lock_file/satisfiability.rs @@ -34,7 +34,7 @@ pub fn lock_file_satisfies_project( // result. let channels = project .channels() - .iter() + .into_iter() .map(|channel| rattler_lock::Channel::from(channel.base_url().to_string())) .collect_vec(); if lock_file.metadata.channels.iter().ne(channels.iter()) { diff --git a/src/project/environment.rs b/src/project/environment.rs new file mode 100644 index 000000000..f469bc491 --- /dev/null +++ b/src/project/environment.rs @@ -0,0 +1,277 @@ +use crate::project::errors::{UnknownTask, UnsupportedPlatformError}; +use crate::project::manifest; +use crate::project::manifest::{EnvironmentName, Feature, FeatureName}; +use crate::task::Task; +use crate::Project; +use indexmap::IndexSet; +use itertools::Itertools; +use rattler_conda_types::{Channel, Platform}; +use std::collections::{HashMap, HashSet}; +use std::fmt::Debug; + +/// Describes a single environment from a project manifest. This is used to describe environments +/// that can be installed and activated. +/// +/// This struct is a higher level representation of a [`manifest::Environment`]. The +/// `manifest::Environment` describes the data stored in the manifest file, while this struct +/// provides methods to easily interact with an environment without having to deal with the +/// structure of the project model. +/// +/// This type does not provide manipulation methods. To modify the data model you should directly +/// interact with the manifest instead. +/// +/// The lifetime `'p` refers to the lifetime of the project that this environment belongs to. +#[derive(Clone)] +pub struct Environment<'p> { + /// The project this environment belongs to. + pub(super) project: &'p Project, + + /// The environment that this environment is based on. + pub(super) environment: &'p manifest::Environment, +} + +impl Debug for Environment<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Environment") + .field("project", &self.project.name()) + .field("environment", &self.environment.name) + .finish() + } +} + +impl<'p> Environment<'p> { + /// Returns the name of this environment. + pub fn name(&self) -> &EnvironmentName { + &self.environment.name + } + + /// Returns the manifest definition of this environment. See the documentation of + /// [`Environment`] for an overview of the difference between [`manifest::Environment`] and + /// [`Environment`]. + pub fn manifest(&self) -> &'p manifest::Environment { + self.environment + } + + /// Returns references to the features that make up this environment. The default feature is + /// always added at the end. + pub fn features(&self) -> impl Iterator + '_ { + self.environment + .features + .iter() + .map(|feature_name| { + self.project + .manifest + .parsed + .features + .get(&FeatureName::Named(feature_name.clone())) + .expect("feature usage should have been validated upfront") + }) + .chain([self.project.manifest.default_feature()]) + } + + /// Returns the channels associated with this environment. + /// + /// Users can specify custom channels on a per feature basis. This method collects and + /// deduplicates all the channels from all the features in the order they are defined in the + /// manifest. + /// + /// If a feature does not specify any channel the default channels from the project metadata are + /// used instead. However, these are not considered during deduplication. This means the default + /// channels are always added to the end of the list. + pub fn channels(&self) -> IndexSet<&'p Channel> { + self.features() + .filter_map(|feature| match feature.name { + // Use the user-specified channels of each feature if the feature defines them. Only + // for the default feature do we use the default channels from the project metadata + // if the feature itself does not specify any channels. This guarantees that the + // channels from the default feature are always added to the end of the list. + FeatureName::Named(_) => feature.channels.as_deref(), + FeatureName::Default => feature + .channels + .as_deref() + .or(Some(&self.project.manifest.parsed.project.channels)), + }) + .flatten() + .collect() + } + + /// Returns the platforms that this environment is compatible with. + /// + /// Which platforms an environment support depends on which platforms the selected features of + /// the environment supports. The platforms that are supported by the environment is the + /// intersection of the platforms supported by its features. + /// + /// Features can specify which platforms they support through the `platforms` key. If a feature + /// does not specify any platforms the features defined by the project are used. + pub fn platforms(&self) -> HashSet { + self.features() + .map(|feature| { + match &feature.platforms { + Some(platforms) => &platforms.value, + None => &self.project.manifest.parsed.project.platforms.value, + } + .iter() + .copied() + .collect::>() + }) + .reduce(|accumulated_platforms, feat| { + accumulated_platforms.intersection(&feat).copied().collect() + }) + .unwrap_or_default() + } + + /// Returns the tasks defined for this environment. + /// + /// Tasks are defined on a per-target per-feature per-environment basis. + /// + /// If a `platform` is specified but this environment doesn't support the specified platform, + /// an [`UnsupportedPlatformError`] error is returned. + pub fn tasks( + &self, + platform: Option, + ) -> Result, UnsupportedPlatformError> { + self.validate_platform_support(platform)?; + let result = self + .features() + .flat_map(|feature| feature.targets.resolve(platform)) + .collect_vec() + .into_iter() + .rev() // Reverse to get the most specific targets last. + .flat_map(|target| target.tasks.iter()) + .map(|(name, task)| (name.as_str(), task)) + .collect(); + Ok(result) + } + + /// Returns the task with the given `name` and for the specified `platform` or an `UnknownTask` + /// which explains why the task was not available. + pub fn task(&self, name: &str, platform: Option) -> Result<&'p Task, UnknownTask> { + match self.tasks(platform).map(|tasks| tasks.get(name).copied()) { + Err(_) | Ok(None) => Err(UnknownTask { + project: self.project, + environment: self.name().clone(), + platform, + task_name: name.to_string(), + }), + Ok(Some(task)) => Ok(task), + } + } + + /// Validates that the given platform is supported by this environment. + fn validate_platform_support( + &self, + platform: Option, + ) -> Result<(), UnsupportedPlatformError> { + if let Some(platform) = platform { + if !self.platforms().contains(&platform) { + return Err(UnsupportedPlatformError { + project: self.project, + environment: self.name().clone(), + platform, + }); + } + } + + Ok(()) + } +} + +#[cfg(test)] +mod test { + use super::*; + use itertools::Itertools; + use std::path::Path; + + #[test] + fn test_default_channels() { + let manifest = Project::from_str( + Path::new(""), + r#" + [project] + name = "foobar" + channels = ["foo", "bar"] + platforms = [] + "#, + ) + .unwrap(); + + let channels = manifest + .default_environment() + .channels() + .into_iter() + .map(Channel::canonical_name) + .collect_vec(); + assert_eq!( + channels, + vec![ + "https://conda.anaconda.org/foo/", + "https://conda.anaconda.org/bar/" + ] + ); + } + + // TODO: Add a test to verify that feature specific channels work as expected. + + #[test] + fn test_default_platforms() { + let manifest = Project::from_str( + Path::new(""), + r#" + [project] + name = "foobar" + channels = [] + platforms = ["linux-64", "osx-64"] + "#, + ) + .unwrap(); + + let channels = manifest.default_environment().platforms(); + assert_eq!( + channels, + HashSet::from_iter([Platform::Linux64, Platform::Osx64,]) + ); + } + + #[test] + fn test_default_tasks() { + let manifest = Project::from_str( + Path::new(""), + r#" + [project] + name = "foobar" + channels = [] + platforms = ["linux-64"] + + [tasks] + foo = "echo default" + + [target.linux-64.tasks] + foo = "echo linux" + "#, + ) + .unwrap(); + + let task = manifest + .default_environment() + .task("foo", None) + .unwrap() + .as_single_command() + .unwrap(); + + assert_eq!(task, "echo default"); + + let task_osx = manifest + .default_environment() + .task("foo", Some(Platform::Linux64)) + .unwrap() + .as_single_command() + .unwrap(); + + assert_eq!(task_osx, "echo linux"); + + assert!(manifest + .default_environment() + .tasks(Some(Platform::Osx64)) + .is_err()) + } +} diff --git a/src/project/errors.rs b/src/project/errors.rs new file mode 100644 index 000000000..7fa663aa5 --- /dev/null +++ b/src/project/errors.rs @@ -0,0 +1,78 @@ +use crate::project::manifest::EnvironmentName; +use crate::Project; +use itertools::Itertools; +use miette::{Diagnostic, LabeledSpan}; +use rattler_conda_types::Platform; +use std::error::Error; +use std::fmt::{Display, Formatter}; +use thiserror::Error; + +/// An error that occurs when data is requested for a platform that is not supported. +/// TODO: Make this error better by also explaining to the user why a certain platform was not +/// supported and with suggestions as how to fix it. +#[derive(Debug, Clone)] +pub struct UnsupportedPlatformError<'p> { + /// The project that the platform is not supported for. + pub project: &'p Project, + + /// The environment that the platform is not supported for. + pub environment: EnvironmentName, + + /// The platform that was requested + pub platform: Platform, +} + +impl<'p> Error for UnsupportedPlatformError<'p> {} + +impl<'p> Display for UnsupportedPlatformError<'p> { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match &self.environment { + EnvironmentName::Default => { + write!(f, "the project does not support '{}'", self.platform) + } + EnvironmentName::Named(name) => write!( + f, + "the environment '{}' does not support '{}'", + name, self.platform + ), + } + } +} + +impl<'p> Diagnostic for UnsupportedPlatformError<'p> { + fn code(&self) -> Option> { + Some(Box::new("unsupported-platform".to_string())) + } + + fn help(&self) -> Option> { + let env = self.project.environment(&self.environment)?; + Some(Box::new(format!( + "supported platforms are {}", + env.platforms().into_iter().format(", ") + ))) + } + + fn labels(&self) -> Option + '_>> { + None + } +} + +/// An error that occurs when a task is requested which could not be found. +/// TODO: Make this error better. +/// - Include names that might have been meant instead +/// - If the tasks is only available for a certain platform, explain that. +#[derive(Debug, Clone, Diagnostic, Error)] +#[error("the task '{task_name}' could not be found")] +pub struct UnknownTask<'p> { + /// The project that the platform is not supported for. + pub project: &'p Project, + + /// The environment that the platform is not supported for. + pub environment: EnvironmentName, + + /// The platform that was requested (if any) + pub platform: Option, + + /// The name of the task + pub task_name: String, +} diff --git a/src/project/manifest/environment.rs b/src/project/manifest/environment.rs index d1bc31f44..f92c60ee5 100644 --- a/src/project/manifest/environment.rs +++ b/src/project/manifest/environment.rs @@ -1,4 +1,4 @@ -use crate::utils::spanned::PixiSpanned; +use crate::consts; /// The name of an environment. This is either a string or default for the default environment. #[derive(Clone, Debug, Eq, PartialEq, PartialOrd, Ord, Hash)] @@ -8,11 +8,12 @@ pub enum EnvironmentName { } impl EnvironmentName { - /// Returns the name of the environment or `None` if this is the default environment. - pub fn name(&self) -> Option<&str> { + /// Returns the name of the environment. This is either the name of the environment or the name + /// of the default environment. + pub fn as_str(&self) -> &str { match self { - EnvironmentName::Default => None, - EnvironmentName::Named(name) => Some(name), + EnvironmentName::Default => consts::DEFAULT_ENVIRONMENT_NAME, + EnvironmentName::Named(name) => name.as_str(), } } } @@ -30,7 +31,10 @@ pub struct Environment { /// /// Note that the default feature is always added to the set of features that make up the /// environment. - pub features: PixiSpanned>, + pub features: Vec, + + /// The optional location of where the features are defined in the manifest toml. + pub features_source_loc: Option>, /// An optional solver-group. Multiple environments can share the same solve-group. All the /// dependencies of the environment that share the same solve-group will be solved together. diff --git a/src/project/manifest/mod.rs b/src/project/manifest/mod.rs index 37c87c34d..d8e73d780 100644 --- a/src/project/manifest/mod.rs +++ b/src/project/manifest/mod.rs @@ -7,13 +7,9 @@ mod python; mod serde; mod system_requirements; mod target; +mod validation; -use crate::{ - consts, - project::{manifest::target::Targets, SpecType}, - task::Task, - utils::spanned::PixiSpanned, -}; +use crate::{consts, project::SpecType, task::Task, utils::spanned::PixiSpanned}; use ::serde::{Deserialize, Deserializer}; pub use activation::Activation; pub use environment::{Environment, EnvironmentName}; @@ -21,7 +17,7 @@ pub use feature::{Feature, FeatureName}; use indexmap::IndexMap; use itertools::Itertools; pub use metadata::ProjectMetadata; -use miette::{Context, IntoDiagnostic, LabeledSpan, NamedSource, Report}; +use miette::{IntoDiagnostic, LabeledSpan, NamedSource}; pub use python::PyPiRequirement; use rattler_conda_types::{ Channel, ChannelConfig, MatchSpec, NamelessMatchSpec, PackageName, Platform, Version, @@ -29,12 +25,11 @@ use rattler_conda_types::{ use serde_with::{serde_as, DisplayFromStr, PickFirst}; use std::{ collections::HashMap, - ops::Range, path::{Path, PathBuf}, str::FromStr, }; pub use system_requirements::{LibCFamilyAndVersion, LibCSystemRequirement, SystemRequirements}; -pub use target::{Target, TargetSelector}; +pub use target::{Target, TargetSelector, Targets}; use toml_edit::{value, Array, Document, Item, Table, TomlError, Value}; /// Handles the project's manifest file. @@ -490,6 +485,11 @@ impl Manifest { pub fn default_environment(&self) -> &Environment { self.parsed.default_environment() } + + /// Returns the environment with the given name or `None` if it does not exist. + pub fn environment(&self, name: &EnvironmentName) -> Option<&Environment> { + self.parsed.environments.get(name) + } } /// Ensures that the specified TOML target table exists within a given document, @@ -613,9 +613,11 @@ impl ProjectManifest { /// feature. The default environment can be overwritten by a environment named `default`. pub fn default_environment(&self) -> &Environment { let envs = &self.environments; - envs.get(&EnvironmentName::Named(String::from("default"))) - .or_else(|| envs.get(&EnvironmentName::Default)) - .expect("default environment should always exist") + envs.get(&EnvironmentName::Named(String::from( + consts::DEFAULT_ENVIRONMENT_NAME, + ))) + .or_else(|| envs.get(&EnvironmentName::Default)) + .expect("default environment should always exist") } } @@ -701,7 +703,8 @@ impl<'de> Deserialize<'de> for ProjectManifest { // Construct a default environment let default_environment = Environment { name: EnvironmentName::Default, - features: Vec::new().into(), + features: Vec::new(), + features_source_loc: None, solve_group: None, }; @@ -713,95 +716,6 @@ impl<'de> Deserialize<'de> for ProjectManifest { } } -impl ProjectManifest { - /// Validate the - pub fn validate(&self, source: NamedSource, root_folder: &Path) -> miette::Result<()> { - // Check if the targets are defined for existing platforms - for feature in self.features.values() { - let platforms = feature - .platforms - .as_ref() - .unwrap_or(&self.project.platforms); - for target_sel in feature.targets.user_defined_selectors() { - match target_sel { - TargetSelector::Platform(p) => { - if !platforms.as_ref().contains(p) { - return Err(create_unsupported_platform_report( - source, - feature.targets.source_loc(target_sel).unwrap_or_default(), - p, - feature, - )); - } - } - } - } - } - - // parse the SPDX license expression to make sure that it is a valid expression. - if let Some(spdx_expr) = &self.project.license { - spdx::Expression::parse(spdx_expr) - .into_diagnostic() - .with_context(|| { - format!( - "failed to parse the SPDX license expression '{}'", - spdx_expr - ) - })?; - } - - let check_file_existence = |x: &Option| { - if let Some(path) = x { - let full_path = root_folder.join(path); - if !full_path.exists() { - return Err(miette::miette!( - "the file '{}' does not exist", - full_path.display() - )); - } - } - Ok(()) - }; - - check_file_existence(&self.project.license_file)?; - check_file_existence(&self.project.readme)?; - - Ok(()) - } -} - -// Create an error report for using a platform that is not supported by the project. -fn create_unsupported_platform_report( - source: NamedSource, - span: Range, - platform: &Platform, - feature: &Feature, -) -> Report { - miette::miette!( - labels = vec![LabeledSpan::at( - span, - format!("'{}' is not a supported platform", platform) - )], - help = format!( - "Add '{platform}' to the `{}` array of the {} manifest.", - consts::PROJECT_MANIFEST, - if feature.platforms.is_some() { - format!( - "feature.{}.platforms", - feature - .name - .name() - .expect("default feature never defines custom platforms") - ) - } else { - String::from("project.platforms") - } - ), - "targeting a platform that this project does not support" - ) - .with_source_code(source) -} - #[cfg(test)] mod test { use super::*; diff --git a/src/project/manifest/target.rs b/src/project/manifest/target.rs index 84bf39b0b..73b62cff3 100644 --- a/src/project/manifest/target.rs +++ b/src/project/manifest/target.rs @@ -162,7 +162,7 @@ impl<'de> Deserialize<'de> for Target { } /// A collect of targets including a default target. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Default)] pub struct Targets { default_target: Target, diff --git a/src/project/manifest/validation.rs b/src/project/manifest/validation.rs new file mode 100644 index 000000000..37285e05d --- /dev/null +++ b/src/project/manifest/validation.rs @@ -0,0 +1,148 @@ +use crate::project::manifest::{Environment, FeatureName}; +use crate::{ + consts, + project::manifest::{Feature, ProjectManifest, TargetSelector}, +}; +use miette::{IntoDiagnostic, LabeledSpan, NamedSource, Report, WrapErr}; +use rattler_conda_types::Platform; +use std::collections::HashSet; +use std::{ + ops::Range, + path::{Path, PathBuf}, +}; + +impl ProjectManifest { + /// Validate the project manifest. + pub fn validate(&self, source: NamedSource, root_folder: &Path) -> miette::Result<()> { + // Check if the targets are defined for existing platforms + for feature in self.features.values() { + let platforms = feature + .platforms + .as_ref() + .unwrap_or(&self.project.platforms); + for target_sel in feature.targets.user_defined_selectors() { + match target_sel { + TargetSelector::Platform(p) => { + if !platforms.as_ref().contains(p) { + return Err(create_unsupported_platform_report( + source, + feature.targets.source_loc(target_sel).unwrap_or_default(), + p, + feature, + )); + } + } + } + } + } + + // parse the SPDX license expression to make sure that it is a valid expression. + if let Some(spdx_expr) = &self.project.license { + spdx::Expression::parse(spdx_expr) + .into_diagnostic() + .with_context(|| { + format!( + "failed to parse the SPDX license expression '{}'", + spdx_expr + ) + })?; + } + + let check_file_existence = |x: &Option| { + if let Some(path) = x { + let full_path = root_folder.join(path); + if !full_path.exists() { + return Err(miette::miette!( + "the file '{}' does not exist", + full_path.display() + )); + } + } + Ok(()) + }; + + check_file_existence(&self.project.license_file)?; + check_file_existence(&self.project.readme)?; + + // Validate the environments defined in the project + for (_name, env) in self.environments.iter() { + if let Err(report) = self.validate_environment(env) { + return Err(report.with_source_code(source)); + } + } + + Ok(()) + } + + /// Validates that the given environment is valid. + fn validate_environment(&self, env: &Environment) -> Result<(), Report> { + let mut features_seen = HashSet::new(); + + for feature in env.features.iter() { + // Make sure that the environment does not have any duplicate features. + if !features_seen.insert(feature) { + return Err(miette::miette!( + labels = vec![LabeledSpan::at( + env.features_source_loc.clone().unwrap_or_default(), + format!("the feature '{}' was defined more than once.", feature) + )], + help = + "since the order of the features matters a duplicate feature is ambiguous", + "the feature '{}' is defined multiple times in the environment '{}'", + feature, + env.name.as_str() + )); + } + + // Make sure that every feature actually exists. + if !self + .features + .contains_key(&FeatureName::Named(feature.clone())) + { + return Err(miette::miette!( + labels = vec![LabeledSpan::at( + env.features_source_loc.clone().unwrap_or_default(), + format!("unknown feature '{}'", feature) + )], + help = "add the feature to the project manifest", + "the feature '{}' is not defined in the project manifest", + feature + )); + } + } + + Ok(()) + } +} + +// Create an error report for using a platform that is not supported by the project. +fn create_unsupported_platform_report( + source: NamedSource, + span: Range, + platform: &Platform, + feature: &Feature, +) -> Report { + miette::miette!( + labels = vec![LabeledSpan::at( + span, + format!("'{}' is not a supported platform", platform) + )], + help = format!( + "Add '{platform}' to the `{}` array of the {} manifest.", + consts::PROJECT_MANIFEST, + if feature.platforms.is_some() { + format!( + "feature.{}.platforms", + feature + .name + .name() + .expect("default feature never defines custom platforms") + ) + } else { + String::from("project.platforms") + } + ), + "targeting a platform that this project does not support" + ) + .with_source_code(source) +} diff --git a/src/project/mod.rs b/src/project/mod.rs index 499f9d43d..b845b7a9b 100644 --- a/src/project/mod.rs +++ b/src/project/mod.rs @@ -1,14 +1,16 @@ +mod environment; +mod errors; pub mod manifest; pub mod metadata; -use indexmap::IndexMap; +use indexmap::{IndexMap, IndexSet}; use itertools::Itertools; use miette::{IntoDiagnostic, NamedSource, WrapErr}; use once_cell::sync::OnceCell; use rattler_conda_types::{Channel, MatchSpec, NamelessMatchSpec, PackageName, Platform, Version}; use rattler_virtual_packages::VirtualPackage; use rip::{index::PackageDb, normalize_index_url}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::{ env, ffi::OsStr, @@ -17,15 +19,17 @@ use std::{ sync::Arc, }; +use crate::project::manifest::EnvironmentName; use crate::{ consts::{self, PROJECT_MANIFEST}, default_client, task::Task, virtual_packages::non_relevant_virtual_packages_for_platform, }; +use environment::Environment; use manifest::{Manifest, PyPiRequirement, SystemRequirements}; use rip::types::NormalizedPackageName; -use std::fmt::{Display, Formatter}; +use std::fmt::{Debug, Display, Formatter}; use url::Url; /// The dependency types we support @@ -78,6 +82,15 @@ pub struct Project { pub(crate) manifest: Manifest, } +impl Debug for Project { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Project") + .field("root", &self.root) + .field("manifest", &self.manifest) + .finish() + } +} + impl Project { /// Constructs a new instance from an internal manifest representation pub fn from_manifest(manifest: Manifest) -> Self { @@ -88,6 +101,12 @@ impl Project { } } + /// Constructs a project from a manifest. + pub fn from_str(root: &Path, content: &str) -> miette::Result { + let manifest = Manifest::from_str(root, content)?; + Ok(Self::from_manifest(manifest)) + } + /// Discovers the project manifest file in the current directory or any of the parent /// directories. /// This will also set the current working directory to the project root. @@ -190,46 +209,52 @@ impl Project { self.manifest.save() } - /// Returns the channels used by this project - pub fn channels(&self) -> &[Channel] { - &self.manifest.parsed.project.channels + /// Returns the default environment of the project. + pub fn default_environment(&self) -> Environment<'_> { + Environment { + project: self, + environment: self.manifest.default_environment(), + } + } + + /// Returns the environment with the given name or `None` if no such environment exists. + pub fn environment(&self, name: &EnvironmentName) -> Option> { + Some(Environment { + project: self, + environment: self.manifest.environment(name)?, + }) + } + + /// Returns the channels used by this project. + /// + /// TODO: Remove this function and use the channels from the default environment instead. + pub fn channels(&self) -> IndexSet<&Channel> { + self.default_environment().channels() } /// Returns the platforms this project targets - pub fn platforms(&self) -> &[Platform] { - self.manifest.parsed.project.platforms.as_ref().as_slice() + /// + /// TODO: Remove this function and use the platforms from the default environment instead. + pub fn platforms(&self) -> HashSet { + self.default_environment().platforms() } /// Get the tasks of this project + /// + /// TODO: Remove this function and use the tasks from the default environment instead. pub fn tasks(&self, platform: Option) -> HashMap<&str, &Task> { - self.manifest.tasks(platform) + self.default_environment() + .tasks(platform) + .unwrap_or_default() } /// Get the task with the specified `name` or `None` if no such task exists. If `platform` is /// specified then the task will first be looked up in the target specific tasks for the given /// platform. + /// + /// TODO: Remove this function and use the `task` function from the default environment instead. pub fn task_opt(&self, name: &str, platform: Option) -> Option<&Task> { - self.manifest.tasks(platform).get(name).copied() - } - - /// Returns all tasks defined in the project for the given platform - pub fn task_names(&self, platform: Option) -> Vec<&str> { - self.manifest.tasks(platform).keys().copied().collect_vec() - } - - /// Returns names of the tasks that depend on the given task. - pub fn task_names_depending_on(&self, name: impl AsRef) -> Vec<&str> { - let mut tasks = self.manifest.tasks(Some(Platform::current())); - let task = tasks.remove(name.as_ref()); - if task.is_some() { - tasks - .into_iter() - .filter(|(_, c)| c.depends_on().contains(&name.as_ref().to_string())) - .map(|(name, _)| name) - .collect() - } else { - vec![] - } + self.default_environment().task(name, platform).ok() } /// Returns the dependencies of the project. diff --git a/src/repodata.rs b/src/repodata.rs index b8822bb6f..763f803de 100644 --- a/src/repodata.rs +++ b/src/repodata.rs @@ -1,6 +1,7 @@ use crate::{default_authenticated_client, progress, project::Project}; use futures::{stream, StreamExt, TryStreamExt}; use indicatif::ProgressBar; +use itertools::Itertools; use miette::{Context, IntoDiagnostic}; use rattler_conda_types::{Channel, Platform}; use rattler_networking::AuthenticatedClient; @@ -16,18 +17,17 @@ impl Project { } pub async fn fetch_sparse_repodata( - channels: &[Channel], - target_platforms: &[Platform], + channels: impl IntoIterator, + target_platforms: impl IntoIterator, ) -> miette::Result> { - if channels.is_empty() { - return Ok(vec![]); - } + let channels = channels.into_iter(); + let target_platforms = target_platforms.into_iter().collect_vec(); // Determine all the repodata that requires fetching. - let mut fetch_targets = Vec::with_capacity(channels.len() * target_platforms.len()); + let mut fetch_targets = Vec::with_capacity(channels.size_hint().0 * target_platforms.len()); for channel in channels { // Determine the platforms to use for this channel. - let platforms = channel.platforms.as_deref().unwrap_or(target_platforms); + let platforms = channel.platforms.as_deref().unwrap_or(&target_platforms); for platform in platforms { fetch_targets.push((channel.clone(), *platform)); } @@ -39,6 +39,10 @@ pub async fn fetch_sparse_repodata( } } + if fetch_targets.is_empty() { + return Ok(vec![]); + } + // Construct a top-level progress bar let multi_progress = progress::global_multi_progress(); let top_level_progress = multi_progress.add(ProgressBar::new(fetch_targets.len() as u64)); diff --git a/src/task/executable_task.rs b/src/task/executable_task.rs index 6b7b6666d..d5acf1a76 100644 --- a/src/task/executable_task.rs +++ b/src/task/executable_task.rs @@ -254,7 +254,7 @@ mod tests { let executable_tasks = ExecutableTask::from_cmd_args( &project, vec!["top".to_string(), "--test".to_string()], - Some(Platform::current()), + None, ) .get_ordered_dependencies() .await @@ -293,14 +293,11 @@ mod tests { let manifest = Manifest::from_str(Path::new(""), file_content.to_string()).unwrap(); let project = Project::from_manifest(manifest); - let executable_tasks = ExecutableTask::from_cmd_args( - &project, - vec!["top".to_string()], - Some(Platform::current()), - ) - .get_ordered_dependencies() - .await - .unwrap(); + let executable_tasks = + ExecutableTask::from_cmd_args(&project, vec!["top".to_string()], None) + .get_ordered_dependencies() + .await + .unwrap(); let ordered_task_names: Vec<_> = executable_tasks .iter() diff --git a/tests/init_tests.rs b/tests/init_tests.rs index 90a13a015..56716c72b 100644 --- a/tests/init_tests.rs +++ b/tests/init_tests.rs @@ -46,12 +46,12 @@ async fn specific_channel() { let project = pixi.project().unwrap(); // The only channel should be the "random" channel - let channels = project.channels(); + let channels = Vec::from_iter(project.channels()); assert_eq!( channels, - &[ - Channel::from_str("random", &ChannelConfig::default()).unwrap(), - Channel::from_str("foobar", &ChannelConfig::default()).unwrap() + [ + &Channel::from_str("random", &ChannelConfig::default()).unwrap(), + &Channel::from_str("foobar", &ChannelConfig::default()).unwrap() ] ) } @@ -68,9 +68,9 @@ async fn default_channel() { let project = pixi.project().unwrap(); // The only channel should be the "conda-forge" channel - let channels = project.channels(); + let channels = Vec::from_iter(project.channels()); assert_eq!( channels, - &[Channel::from_str("conda-forge", &ChannelConfig::default()).unwrap()] + [&Channel::from_str("conda-forge", &ChannelConfig::default()).unwrap()] ) }