diff --git a/.github/workflows/moon.yml b/.github/workflows/moon.yml index c135f590672..9c556101f84 100644 --- a/.github/workflows/moon.yml +++ b/.github/workflows/moon.yml @@ -28,8 +28,6 @@ jobs: with: fetch-depth: 0 - uses: actions/setup-node@v3 - with: - cache: yarn - uses: moonrepo/setup-rust@v0 - uses: moonrepo/tool-version-action@v1 with: diff --git a/Cargo.lock b/Cargo.lock index 116dac1b53f..c935fb28298 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2798,6 +2798,7 @@ dependencies = [ "semver", "serde", "serde_json", + "serde_yaml", "starbase_sandbox", "strum", ] @@ -4599,9 +4600,9 @@ dependencies = [ [[package]] name = "schematic" -version = "0.4.0" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "537808e6ed80f9396fc9db31dfa3ac909e13a84c25f705e880914f155e5ae32a" +checksum = "60dfb5e73c7a786d1cc95c98990183aeca3ccbe96fd4c11bf56911cfb4be85af" dependencies = [ "garde", "miette", @@ -4616,9 +4617,9 @@ dependencies = [ [[package]] name = "schematic_macros" -version = "0.4.0" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d76c57d243d0c069673640ea2adb739c3c3859137cdbb417ae710f686c62925" +checksum = "b6fa8016e729d2314413e8d89557607138f87eec7f03c5afff0011a70d48c16d" dependencies = [ "darling 0.20.1", "proc-macro2", diff --git a/crates/core/config/tests/project_test.rs b/crates/core/config/tests/project_test.rs index b0a9c305d78..525b38ffbb3 100644 --- a/crates/core/config/tests/project_test.rs +++ b/crates/core/config/tests/project_test.rs @@ -732,9 +732,7 @@ mod tags { } #[test] - #[should_panic( - expected = "Invalid identifier foo bar. May only contain alpha-numeric characters, dashes (-), slashes (/), underscores (_), and dots (.)" - )] + #[should_panic(expected = "Invalid format for foo bar")] fn invalid_format() { figment::Jail::expect_with(|jail| { jail.create_file(super::CONFIG_PROJECT_FILENAME, "tags: ['foo bar']")?; diff --git a/nextgen/common/src/id.rs b/nextgen/common/src/id.rs index 780a18b0b3c..3d3df009e7e 100644 --- a/nextgen/common/src/id.rs +++ b/nextgen/common/src/id.rs @@ -18,7 +18,7 @@ pub static ID_PATTERN: Lazy = Lazy::new(|| Regex::new(&format!("^([A-Za-z@]{{1}}{})$", ID_CHARS)).unwrap()); #[derive(Error, Debug)] -#[error("Invalid identifier {}. May only contain alpha-numeric characters, dashes (-), slashes (/), underscores (_), and dots (.).", .0.style(Style::Id))] +#[error("Invalid format for {}, may only contain alpha-numeric characters, dashes (-), slashes (/), underscores (_), and dots (.).", .0.style(Style::Id))] pub struct IdError(String); #[derive(Clone, Debug, Default, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize)] diff --git a/nextgen/config/Cargo.toml b/nextgen/config/Cargo.toml index 4a5b7948a6e..3656d65fb91 100644 --- a/nextgen/config/Cargo.toml +++ b/nextgen/config/Cargo.toml @@ -8,9 +8,10 @@ moon_common = { path = "../common" } moon_target = { path = "../target" } proto_cli = { workspace = true } rustc-hash = { workspace = true } -schematic = { version = "0.4.0", default-features = false, features = ["yaml", "valid_url"] } +schematic = { version = "0.4.2", default-features = false, features = ["yaml", "valid_url"] } semver = "1.0.17" serde = { workspace = true } +serde_yaml = { workspace = true } strum = { workspace = true } [dev-dependencies] diff --git a/nextgen/config/src/inherited_tasks_config.rs b/nextgen/config/src/inherited_tasks_config.rs index 13dbfb2eb0c..44a435e1216 100644 --- a/nextgen/config/src/inherited_tasks_config.rs +++ b/nextgen/config/src/inherited_tasks_config.rs @@ -1,7 +1,7 @@ use crate::language_platform::{LanguageType, PlatformType}; +use crate::portable_path::PortablePath; use crate::project::TaskConfig; use crate::project_config::ProjectType; -use crate::relative_path::RelativePath; use crate::FilePath; use moon_common::{consts, Id}; use moon_target::Target; @@ -26,7 +26,7 @@ where } /// Docs: https://moonrepo.dev/docs/config/tasks -#[derive(Debug, Default, Clone, Config)] +#[derive(Debug, Clone, Config)] pub struct InheritedTasksConfig { #[setting( default = "https://moonrepo.dev/schemas/tasks.json", @@ -38,13 +38,13 @@ pub struct InheritedTasksConfig { pub extends: Option, #[setting(merge = merge_fxhashmap)] - pub file_groups: FxHashMap>, + pub file_groups: FxHashMap>, #[setting(merge = merge::append_vec)] pub implicit_deps: Vec, #[setting(merge = merge::append_vec)] - pub implicit_inputs: Vec, + pub implicit_inputs: Vec, #[setting(nested, merge = merge::merge_btreemap)] pub tasks: BTreeMap, @@ -74,7 +74,11 @@ pub struct InheritedTasksManager { impl InheritedTasksManager { pub fn add_config(&mut self, path: &Path, config: PartialInheritedTasksConfig) { - let name = path.file_name().unwrap_or_default().to_str().unwrap(); + let name = path + .file_name() + .unwrap_or_default() + .to_str() + .unwrap_or_default(); let name = if name == consts::CONFIG_TASKS_FILENAME { "*" @@ -124,7 +128,7 @@ impl InheritedTasksManager { language: &LanguageType, project: &ProjectType, tags: &[Id], - ) -> InheritedTasksConfig { + ) -> Result { let mut config = PartialInheritedTasksConfig::default(); for lookup in self.get_lookup_order(platform, language, project, tags) { @@ -135,7 +139,7 @@ impl InheritedTasksManager { if let Some(tasks) = &mut managed_config.tasks { for task in tasks.values_mut() { // Automatically set this lookup as an input - let global_lookup = RelativePath::WorkspaceFile(FilePath(format!( + let global_lookup = PortablePath::WorkspaceFile(FilePath(format!( ".moon/tasks/{lookup}.yml" ))); @@ -153,10 +157,10 @@ impl InheritedTasksManager { } } - config.merge(&(), managed_config).unwrap(); + config.merge(&(), managed_config)?; } } - InheritedTasksConfig::from_partial(config) + InheritedTasksConfig::from_partial(&(), config, false) } } diff --git a/nextgen/config/src/language_platform.rs b/nextgen/config/src/language_platform.rs index 4d432a9e699..ea73928cbd5 100644 --- a/nextgen/config/src/language_platform.rs +++ b/nextgen/config/src/language_platform.rs @@ -140,7 +140,7 @@ impl From for PlatformType { // Deno and Bun are not covered here! LanguageType::JavaScript | LanguageType::TypeScript => PlatformType::Node, LanguageType::Rust => PlatformType::Rust, - // TODO: Move to these to their own platform once it's been implemented! + // TODO: Move these to their own platform once it's been implemented! LanguageType::Go | LanguageType::Php | LanguageType::Python diff --git a/nextgen/config/src/lib.rs b/nextgen/config/src/lib.rs index 93c0711ed1b..56059745795 100644 --- a/nextgen/config/src/lib.rs +++ b/nextgen/config/src/lib.rs @@ -1,8 +1,8 @@ mod inherited_tasks_config; mod language_platform; +mod portable_path; mod project; mod project_config; -mod relative_path; mod template; mod template_config; mod toolchain; @@ -13,9 +13,9 @@ mod workspace_config; pub use inherited_tasks_config::*; pub use language_platform::*; +pub use portable_path::*; pub use project::*; pub use project_config::*; -pub use relative_path::*; pub use template::*; pub use template_config::*; pub use toolchain::*; diff --git a/nextgen/config/src/portable_path.rs b/nextgen/config/src/portable_path.rs new file mode 100644 index 00000000000..206f7c3de8a --- /dev/null +++ b/nextgen/config/src/portable_path.rs @@ -0,0 +1,156 @@ +use crate::validate::{validate_child_or_root_path, validate_child_relative_path}; +use schematic::ValidateError; +use serde::{de, Deserialize, Deserializer, Serialize}; + +// Not accurate at all but good enough... +fn is_glob(value: &str) -> bool { + value.contains("**") || value.contains('*') || value.contains('{') || value.contains('[') +} + +pub trait Portable: Sized { + fn from_str(path: &str) -> Result; +} + +macro_rules! path_type { + ($name:ident) => { + #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)] + pub struct $name(pub String); + + impl TryFrom for $name { + type Error = ValidateError; + + fn try_from(value: String) -> Result { + $name::from_str(&value) + } + } + + impl TryFrom<&String> for $name { + type Error = ValidateError; + + fn try_from(value: &String) -> Result { + $name::from_str(value) + } + } + + impl TryFrom<&str> for $name { + type Error = ValidateError; + + fn try_from(value: &str) -> Result { + $name::from_str(value) + } + } + + impl<'de> Deserialize<'de> for $name { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + + $name::from_str(&value).map_err(|error| de::Error::custom(error.message)) + } + } + }; +} + +// Represents any file glob pattern. +path_type!(GlobPath); + +impl Portable for GlobPath { + fn from_str(value: &str) -> Result { + Ok(GlobPath(value.into())) + } +} + +// Represents a project-relative file glob pattern. +path_type!(ProjectFileGlob); + +impl Portable for ProjectFileGlob { + fn from_str(value: &str) -> Result { + validate_child_relative_path(value)?; + + if value.starts_with('/') { + return Err(ValidateError::new( + "workspace relative paths are not supported", + )); + } + + Ok(ProjectFileGlob(value.into())) + } +} + +// Represents any file system path. +path_type!(FilePath); + +impl Portable for FilePath { + fn from_str(value: &str) -> Result { + if is_glob(value) { + return Err(ValidateError::new( + "globs are not supported, expected a literal file path", + )); + } + + Ok(FilePath(value.into())) + } +} + +// Represents a project-relative file system path. +path_type!(ProjectFilePath); + +impl Portable for ProjectFilePath { + fn from_str(value: &str) -> Result { + if is_glob(value) { + return Err(ValidateError::new( + "globs are not supported, expected a literal file path", + )); + } + + validate_child_relative_path(value)?; + + if value.starts_with('/') { + return Err(ValidateError::new( + "workspace relative paths are not supported", + )); + } + + Ok(ProjectFilePath(value.into())) + } +} + +// Represents either a workspace or project relative glob/path, or env var. +// Workspace paths are prefixed with "/", and env vars with "$". +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub enum PortablePath { + EnvVar(String), + ProjectFile(FilePath), + ProjectGlob(GlobPath), + WorkspaceFile(FilePath), + WorkspaceGlob(GlobPath), +} + +impl Portable for PortablePath { + fn from_str(value: &str) -> Result { + if let Some(env_var) = value.strip_prefix('$') { + return Ok(PortablePath::EnvVar(env_var.to_owned())); + } + + validate_child_or_root_path(value)?; + + Ok(match (value.starts_with('/'), is_glob(value)) { + (true, true) => PortablePath::WorkspaceGlob(GlobPath::from_str(&value[1..])?), + (true, false) => PortablePath::WorkspaceFile(FilePath::from_str(&value[1..])?), + (false, true) => PortablePath::ProjectGlob(GlobPath::from_str(value)?), + (false, false) => PortablePath::ProjectFile(FilePath::from_str(value)?), + }) + } +} + +impl<'de> Deserialize<'de> for PortablePath { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + PortablePath::from_str(&String::deserialize(deserializer)?) + .map_err(|error| de::Error::custom(error.message)) + } +} diff --git a/nextgen/config/src/project/task_config.rs b/nextgen/config/src/project/task_config.rs index 81a01be305b..9a1ed3c4f31 100644 --- a/nextgen/config/src/project/task_config.rs +++ b/nextgen/config/src/project/task_config.rs @@ -1,9 +1,10 @@ use crate::language_platform::PlatformType; +use crate::portable_path::PortablePath; use crate::project::{PartialTaskOptionsConfig, TaskOptionsConfig}; -use crate::relative_path::RelativePath; -use moon_target::Target; +use crate::validate::validate_no_env_var_in_path; +use moon_target::{Target, TargetScope}; use rustc_hash::FxHashMap; -use schematic::{config_enum, Config, ValidateError}; +use schematic::{config_enum, Config, ConfigError, ConfigLoader, Segment, ValidateError}; use strum::Display; fn validate_command( @@ -35,6 +36,19 @@ fn validate_command( Ok(()) } +fn validate_deps(deps: &[Target], _task: &TaskConfig, _ctx: &C) -> Result<(), ValidateError> { + for (i, dep) in deps.iter().enumerate() { + if matches!(dep.scope, TargetScope::All | TargetScope::Tag(_)) { + return Err(ValidateError::with_segment( + "target scope not supported as a task dependency", + Segment::Index(i), + )); + } + } + + Ok(()) +} + config_enum!( #[derive(Default, Display)] pub enum TaskType { @@ -68,19 +82,21 @@ pub struct TaskConfig { pub args: TaskCommandArgs, + #[setting(validate = validate_deps)] pub deps: Vec, pub env: FxHashMap, // TODO #[setting(skip)] - pub global_inputs: Vec, + pub global_inputs: Vec, - pub inputs: Vec, + pub inputs: Vec, pub local: bool, - pub outputs: Vec, + #[setting(validate = validate_no_env_var_in_path)] + pub outputs: Vec, #[setting(nested)] pub options: TaskOptionsConfig, @@ -88,5 +104,15 @@ pub struct TaskConfig { pub platform: PlatformType, #[setting(rename = "type")] - pub type_of: TaskType, + pub type_of: Option, +} + +impl TaskConfig { + pub fn parse>(code: T) -> Result { + let result = ConfigLoader::::yaml() + .code(code.as_ref())? + .load()?; + + Ok(result.config) + } } diff --git a/nextgen/config/src/project/task_options_config.rs b/nextgen/config/src/project/task_options_config.rs index bc68c411662..a2f90e8e5aa 100644 --- a/nextgen/config/src/project/task_options_config.rs +++ b/nextgen/config/src/project/task_options_config.rs @@ -1,33 +1,60 @@ -use crate::relative_path::RelativePath; +use crate::portable_path::PortablePath; use schematic::{config_enum, Config, ValidateError}; +use serde::{de, Deserialize, Deserializer, Serialize}; +use serde_yaml::Value; -fn validate_affected_files( - file: &TaskOptionAffectedFiles, +fn validate_env_file( + env_file: &TaskOptionEnvFile, _data: &D, _ctx: &C, ) -> Result<(), ValidateError> { - if let TaskOptionAffectedFiles::Value(value) = file { - if value != "args" && value != "env" { - return Err(ValidateError::new("expected `args`, `env`, or a boolean")); - } + if let TaskOptionEnvFile::File(file) = env_file { + match file { + PortablePath::EnvVar(_) => { + return Err(ValidateError::new( + "environment variables are not supported", + )); + } + PortablePath::ProjectGlob(_) | PortablePath::WorkspaceGlob(_) => { + return Err(ValidateError::new("globs are not supported")); + } + _ => {} + }; } Ok(()) } -config_enum!( - #[serde(untagged, expecting = "expected `args`, `env`, or a boolean")] - pub enum TaskOptionAffectedFiles { - Enabled(bool), - Value(String), +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(untagged, rename_all = "kebab-case")] +pub enum TaskOptionAffectedFiles { + Args, + Env, + Enabled(bool), +} + +impl<'de> Deserialize<'de> for TaskOptionAffectedFiles { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + match Value::deserialize(deserializer)? { + Value::Bool(value) => Ok(TaskOptionAffectedFiles::Enabled(value)), + Value::String(value) if value == "args" || value == "env" => Ok(if value == "args" { + TaskOptionAffectedFiles::Args + } else { + TaskOptionAffectedFiles::Env + }), + _ => Err(de::Error::custom("expected `args`, `env`, or a boolean")), + } } -); +} config_enum!( #[serde(untagged, expecting = "expected a boolean or a file system path")] pub enum TaskOptionEnvFile { Enabled(bool), - File(RelativePath), + File(PortablePath), } ); @@ -55,12 +82,12 @@ config_enum!( #[derive(Debug, Clone, Config)] pub struct TaskOptionsConfig { - #[setting(validate = validate_affected_files)] pub affected_files: Option, #[setting(default = true)] pub cache: bool, + #[setting(validate = validate_env_file)] pub env_file: Option, pub merge_args: TaskMergeStrategy, diff --git a/nextgen/config/src/project_config.rs b/nextgen/config/src/project_config.rs index 94f9965fce1..cdedd7a9f65 100644 --- a/nextgen/config/src/project_config.rs +++ b/nextgen/config/src/project_config.rs @@ -1,11 +1,11 @@ // moon.yml use crate::language_platform::{LanguageType, PlatformType}; +use crate::portable_path::PortablePath; use crate::project::*; -use crate::relative_path::RelativePath; -use moon_common::Id; +use moon_common::{consts, Id}; use rustc_hash::FxHashMap; -use schematic::{color, config_enum, Config, ConfigError, ConfigLoader, ValidateError}; +use schematic::{color, config_enum, validate, Config, ConfigError, ConfigLoader, ValidateError}; use std::collections::BTreeMap; use std::path::Path; use strum::Display; @@ -40,6 +40,7 @@ config_enum!( pub struct ProjectMetadataConfig { pub name: Option, + #[setting(validate = validate::not_empty)] pub description: String, pub owner: Option, @@ -56,8 +57,8 @@ config_enum!( expecting = "expected a project name or dependency config object" )] pub enum ProjectDependsOn { - String(String), - Object { id: String, scope: DependencyScope }, + String(Id), + Object { id: Id, scope: DependencyScope }, } ); @@ -74,7 +75,7 @@ pub struct ProjectConfig { pub env: FxHashMap, - pub file_groups: FxHashMap>, + pub file_groups: FxHashMap>, pub language: LanguageType, @@ -99,18 +100,32 @@ pub struct ProjectConfig { } impl ProjectConfig { - pub fn load, F: AsRef>( - workspace_root: T, - path: F, + pub fn load, P: AsRef>( + workspace_root: R, + path: P, ) -> Result { let workspace_root = workspace_root.as_ref(); let path = path.as_ref(); let result = ConfigLoader::::yaml() - .label(color::path(path)) + .label(color::path(path.strip_prefix(workspace_root).unwrap())) .file(workspace_root.join(path))? .load()?; Ok(result.config) } + + pub fn load_from, P: AsRef>( + workspace_root: R, + project_source: P, + ) -> Result { + let workspace_root = workspace_root.as_ref(); + + Self::load( + workspace_root, + workspace_root + .join(project_source.as_ref()) + .join(consts::CONFIG_PROJECT_FILENAME), + ) + } } diff --git a/nextgen/config/src/relative_path.rs b/nextgen/config/src/relative_path.rs deleted file mode 100644 index 4cef1ea2e44..00000000000 --- a/nextgen/config/src/relative_path.rs +++ /dev/null @@ -1,126 +0,0 @@ -use crate::validate::{validate_child_or_root_path, validate_child_relative_path}; -use schematic::ValidateError; -use serde::{de, Deserialize, Deserializer, Serialize}; - -// Not accurate at all but good enough... -fn is_glob(value: &str) -> bool { - value.contains("**") || value.contains('*') || value.contains('{') || value.contains('[') -} - -pub trait FromPathStr: Sized { - fn from_path_str(path: &str) -> Result; -} - -macro_rules! path_type { - ($name:ident) => { - #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)] - pub struct $name(pub String); - - impl TryFrom<&str> for $name { - type Error = ValidateError; - - fn try_from(value: &str) -> Result { - $name::from_path_str(value) - } - } - - impl<'de> Deserialize<'de> for $name { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let value = String::deserialize(deserializer)?; - - $name::from_path_str(&value).map_err(|error| de::Error::custom(error.message)) - } - } - }; -} - -// Represents a file glob pattern. -path_type!(GlobPath); - -impl FromPathStr for GlobPath { - fn from_path_str(value: &str) -> Result { - Ok(GlobPath(value.into())) - } -} - -// Represents a file system path. -path_type!(FilePath); - -impl FromPathStr for FilePath { - fn from_path_str(value: &str) -> Result { - if is_glob(value) { - return Err(ValidateError::new( - "globs are not supported, expected a literal file path", - )); - } - - Ok(FilePath(value.into())) - } -} - -// Represents a valid child/project relative file system path. -// Will fail on absolute paths ("/") and parent relative paths ("../"). -#[derive(Clone, Debug, Eq, PartialEq, Serialize)] -pub struct ProjectRelativePath(pub T); - -impl FromPathStr for ProjectRelativePath { - fn from_path_str(value: &str) -> Result { - validate_child_relative_path(value)?; - - if value.starts_with('/') { - return Err(ValidateError::new( - "workspace relative paths are not supported", - )); - } - - let value = T::from_path_str(value)?; - - Ok(ProjectRelativePath(value)) - } -} - -impl<'de, T: FromPathStr> Deserialize<'de> for ProjectRelativePath { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let path = String::deserialize(deserializer)?; - - ProjectRelativePath::from_path_str(&path).map_err(|error| de::Error::custom(error.message)) - } -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize)] -pub enum RelativePath { - ProjectFile(FilePath), - ProjectGlob(GlobPath), - WorkspaceFile(FilePath), - WorkspaceGlob(GlobPath), -} - -impl FromPathStr for RelativePath { - fn from_path_str(value: &str) -> Result { - validate_child_or_root_path(value)?; - - Ok(match (value.starts_with('/'), is_glob(value)) { - (true, true) => RelativePath::WorkspaceGlob(GlobPath::from_path_str(&value[1..])?), - (true, false) => RelativePath::WorkspaceFile(FilePath::from_path_str(&value[1..])?), - (false, true) => RelativePath::ProjectGlob(GlobPath::from_path_str(value)?), - (false, false) => RelativePath::ProjectFile(FilePath::from_path_str(value)?), - }) - } -} - -impl<'de> Deserialize<'de> for RelativePath { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let value = String::deserialize(deserializer)?; - - RelativePath::from_path_str(&value).map_err(|error| de::Error::custom(error.message)) - } -} diff --git a/nextgen/config/src/template_config.rs b/nextgen/config/src/template_config.rs index 5bd166381a8..e6ea9d39739 100644 --- a/nextgen/config/src/template_config.rs +++ b/nextgen/config/src/template_config.rs @@ -63,16 +63,36 @@ pub struct TemplateConfig { } impl TemplateConfig { - pub fn load>(path: T) -> Result { + pub fn load, P: AsRef>( + workspace_root: R, + path: P, + ) -> Result { + let workspace_root = workspace_root.as_ref(); + let path = path.as_ref(); + let result = ConfigLoader::::yaml() - .label(color::path(path.as_ref())) - .file(path.as_ref())? + .label(color::path( + if let Ok(relative_path) = path.strip_prefix(workspace_root) { + relative_path + } else { + path + }, + )) + .file(path)? .load()?; Ok(result.config) } - pub fn load_from>(root: T) -> Result { - Self::load(root.as_ref().join(consts::CONFIG_TEMPLATE_FILENAME)) + pub fn load_from, P: AsRef>( + workspace_root: R, + template_root: P, + ) -> Result { + Self::load( + workspace_root, + template_root + .as_ref() + .join(consts::CONFIG_TEMPLATE_FILENAME), + ) } } diff --git a/nextgen/config/src/toolchain/deno_config.rs b/nextgen/config/src/toolchain/deno_config.rs index 880fe0327bd..6f48a13259b 100644 --- a/nextgen/config/src/toolchain/deno_config.rs +++ b/nextgen/config/src/toolchain/deno_config.rs @@ -1,4 +1,4 @@ -use crate::relative_path::FilePath; +use crate::portable_path::FilePath; use schematic::Config; /// Docs: https://moonrepo.dev/docs/config/toolchain#deno diff --git a/nextgen/config/src/toolchain/mod.rs b/nextgen/config/src/toolchain/mod.rs index f395f5c2dc3..2981796702e 100644 --- a/nextgen/config/src/toolchain/mod.rs +++ b/nextgen/config/src/toolchain/mod.rs @@ -18,7 +18,7 @@ macro_rules! inherit_tool { config.version = Some(version.to_owned()); } } else { - let mut data = $config::default_values(&())?; + let mut data = $config::default(); data.version = Some(version.to_owned()); self.$tool = Some(data); @@ -50,7 +50,7 @@ macro_rules! inherit_tool_without_version { ($config:ident, $tool:ident, $key:expr, $method:ident) => { pub fn $method(&mut self, proto_tools: &ToolsConfig) -> Result<(), ConfigError> { if self.$tool.is_none() && proto_tools.tools.get($key).is_some() { - self.$tool = Some($config::default_values(&())?); + self.$tool = Some($config::default()); } Ok(()) diff --git a/nextgen/config/src/toolchain/node_config.rs b/nextgen/config/src/toolchain/node_config.rs index 11c65f135e0..d3217a9a9e4 100644 --- a/nextgen/config/src/toolchain/node_config.rs +++ b/nextgen/config/src/toolchain/node_config.rs @@ -65,7 +65,7 @@ config_enum!( #[derive(Debug, Config)] pub struct NpmConfig { - #[setting(env = "MOON_YARN_VERSION", validate = validate_semver)] + #[setting(env = "MOON_NPM_VERSION", validate = validate_semver)] pub version: Option, } @@ -79,7 +79,7 @@ pub struct PnpmConfig { pub struct YarnConfig { pub plugins: Vec, - #[setting(env = "MOON_NPM_VERSION", validate = validate_semver)] + #[setting(env = "MOON_YARN_VERSION", validate = validate_semver)] pub version: Option, } diff --git a/nextgen/config/src/toolchain/typescript_config.rs b/nextgen/config/src/toolchain/typescript_config.rs index 29a07b3a633..ba565633af4 100644 --- a/nextgen/config/src/toolchain/typescript_config.rs +++ b/nextgen/config/src/toolchain/typescript_config.rs @@ -1,4 +1,4 @@ -use crate::relative_path::FilePath; +use crate::portable_path::FilePath; use schematic::Config; /// Docs: https://moonrepo.dev/docs/config/toolchain#typescript diff --git a/nextgen/config/src/toolchain_config.rs b/nextgen/config/src/toolchain_config.rs index 8d8a7de0108..157aef35f3c 100644 --- a/nextgen/config/src/toolchain_config.rs +++ b/nextgen/config/src/toolchain_config.rs @@ -2,7 +2,7 @@ use crate::toolchain::*; use crate::{inherit_tool, inherit_tool_without_version}; -use moon_common::consts; +use moon_common::{color, consts}; use proto::ToolsConfig; use schematic::{validate, Config, ConfigError, ConfigLoader}; use std::path::Path; @@ -60,11 +60,15 @@ impl ToolchainConfig { Ok(()) } - pub fn load>( + pub fn load, P: AsRef>( + workspace_root: R, path: P, proto_tools: &ToolsConfig, ) -> Result { let mut result = ConfigLoader::::yaml() + .label(color::path( + path.as_ref().strip_prefix(workspace_root.as_ref()).unwrap(), + )) .file(path.as_ref())? .load()?; @@ -73,13 +77,15 @@ impl ToolchainConfig { Ok(result.config) } - pub fn load_from>( - workspace_root: T, + pub fn load_from>( + workspace_root: R, proto_tools: &ToolsConfig, ) -> Result { + let workspace_root = workspace_root.as_ref(); + Self::load( + workspace_root, workspace_root - .as_ref() .join(consts::CONFIG_DIRNAME) .join(consts::CONFIG_TOOLCHAIN_FILENAME), proto_tools, diff --git a/nextgen/config/src/validate.rs b/nextgen/config/src/validate.rs index 5f011c9f1a0..3714b29b4e8 100644 --- a/nextgen/config/src/validate.rs +++ b/nextgen/config/src/validate.rs @@ -1,4 +1,5 @@ -use schematic::ValidateError; +use crate::portable_path::PortablePath; +use schematic::{Segment, ValidateError}; use semver::Version; use std::path::Path; @@ -61,3 +62,20 @@ pub fn validate_semver_requirement( Ok(()) } + +pub fn validate_no_env_var_in_path( + paths: &[PortablePath], + _data: &D, + _ctx: &C, +) -> Result<(), ValidateError> { + for (i, path) in paths.iter().enumerate() { + if matches!(path, PortablePath::EnvVar(_)) { + return Err(ValidateError::with_segment( + "environment variables are not supported here", + Segment::Index(i), + )); + } + } + + Ok(()) +} diff --git a/nextgen/config/src/workspace/generator_config.rs b/nextgen/config/src/workspace/generator_config.rs index dc6dc420c90..05b7caba455 100644 --- a/nextgen/config/src/workspace/generator_config.rs +++ b/nextgen/config/src/workspace/generator_config.rs @@ -1,8 +1,8 @@ -use crate::relative_path::{FilePath, ProjectRelativePath}; +use crate::portable_path::FilePath; use schematic::{validate, Config}; -fn default_templates(_ctx: &C) -> Option>> { - Some(vec![ProjectRelativePath(FilePath("./templates".into()))]) +fn default_templates(_ctx: &C) -> Option> { + Some(vec![FilePath("./templates".into())]) } #[derive(Config)] @@ -11,5 +11,5 @@ pub struct GeneratorConfig { validate = validate::not_empty, default = default_templates )] - pub templates: Vec>, + pub templates: Vec, } diff --git a/nextgen/config/src/workspace/notifier_config.rs b/nextgen/config/src/workspace/notifier_config.rs index 17e615ef54b..27ca0439006 100644 --- a/nextgen/config/src/workspace/notifier_config.rs +++ b/nextgen/config/src/workspace/notifier_config.rs @@ -1,4 +1,4 @@ -use moon_common::is_test_env; +// use moon_common::is_test_env; use schematic::{validate, Config, ValidateError}; fn validate_webhook_url, D, C>( @@ -6,9 +6,9 @@ fn validate_webhook_url, D, C>( data: &D, ctx: &C, ) -> Result<(), ValidateError> { - if !is_test_env() { - validate::url_secure(&url, data, ctx)?; - } + // if !is_test_env() { + validate::url_secure(&url, data, ctx)?; + // } Ok(()) } diff --git a/nextgen/config/src/workspace/vcs_config.rs b/nextgen/config/src/workspace/vcs_config.rs index 7c810c774b6..02cf58187ca 100644 --- a/nextgen/config/src/workspace/vcs_config.rs +++ b/nextgen/config/src/workspace/vcs_config.rs @@ -20,6 +20,6 @@ pub struct VcsConfig { pub manager: VcsManager, - #[setting(default = Vec::from(["origin".into(), "upstream".into()]))] + #[setting(default = vec!["origin".into(), "upstream".into()])] pub remote_candidates: Vec, } diff --git a/nextgen/config/src/workspace_config.rs b/nextgen/config/src/workspace_config.rs index eda41b0a492..2f2f3c43ecf 100644 --- a/nextgen/config/src/workspace_config.rs +++ b/nextgen/config/src/workspace_config.rs @@ -1,15 +1,64 @@ // .moon/workspace.yml -use crate::relative_path::{FilePath, GlobPath, ProjectRelativePath}; +use crate::portable_path::{Portable, ProjectFileGlob, ProjectFilePath}; use crate::validate::validate_semver_requirement; use crate::workspace::*; -use moon_common::Id; +use moon_common::{color, consts, Id}; use rustc_hash::FxHashMap; -use schematic::{config_enum, validate, Config, ConfigError, ConfigLoader}; +use schematic::{ + config_enum, validate, Config, ConfigError, ConfigLoader, Segment, SettingPath, ValidateError, +}; use std::path::Path; -type SourceGlob = ProjectRelativePath; -type SourceFile = ProjectRelativePath; +// We can't use serde based types in the enum below to handle validation, +// as serde fails to parse correctly. So we must manually validate here. +fn validate_projects( + projects: &WorkspaceProjects, + _data: &D, + _ctx: &C, +) -> Result<(), ValidateError> { + match projects { + WorkspaceProjects::Both { globs, sources } => { + for (i, g) in globs.iter().enumerate() { + ProjectFileGlob::from_str(g).map_err(|mut error| { + error.path = Some(SettingPath::new(vec![ + Segment::Key("globs".to_owned()), + Segment::Index(i), + ])); + error + })?; + } + + for (k, v) in sources { + ProjectFilePath::from_str(v).map_err(|mut error| { + error.path = Some(SettingPath::new(vec![ + Segment::Key("sources".to_owned()), + Segment::Key(k.to_string()), + ])); + error + })?; + } + } + WorkspaceProjects::Globs(globs) => { + for (i, g) in globs.iter().enumerate() { + ProjectFileGlob::from_str(g).map_err(|mut error| { + error.path = Some(SettingPath::new(vec![Segment::Index(i)])); + error + })?; + } + } + WorkspaceProjects::Sources(sources) => { + for (k, v) in sources { + ProjectFilePath::from_str(v).map_err(|mut error| { + error.path = Some(SettingPath::new(vec![Segment::Key(k.to_string())])); + error + })?; + } + } + }; + + Ok(()) +} config_enum!( #[serde( @@ -18,11 +67,11 @@ config_enum!( )] pub enum WorkspaceProjects { Both { - globs: Vec, - sources: FxHashMap, + globs: Vec, + sources: FxHashMap, }, - Globs(Vec), - Sources(FxHashMap), + Globs(Vec), + Sources(FxHashMap), } ); @@ -56,6 +105,7 @@ pub struct WorkspaceConfig { #[setting(nested)] pub notifier: NotifierConfig, + #[setting(validate = validate_projects)] pub projects: WorkspaceProjects, #[setting(nested)] @@ -72,11 +122,28 @@ pub struct WorkspaceConfig { } impl WorkspaceConfig { - pub fn load>(path: P) -> Result { + pub fn load, P: AsRef>( + workspace_root: R, + path: P, + ) -> Result { let result = ConfigLoader::::yaml() + .label(color::path( + path.as_ref().strip_prefix(workspace_root.as_ref()).unwrap(), + )) .file(path.as_ref())? .load()?; Ok(result.config) } + + pub fn load_from>(workspace_root: P) -> Result { + let workspace_root = workspace_root.as_ref(); + + Self::load( + workspace_root, + workspace_root + .join(consts::CONFIG_DIRNAME) + .join(consts::CONFIG_WORKSPACE_FILENAME), + ) + } } diff --git a/nextgen/config/tests/project_config_test.rs b/nextgen/config/tests/project_config_test.rs new file mode 100644 index 00000000000..1e597d82417 --- /dev/null +++ b/nextgen/config/tests/project_config_test.rs @@ -0,0 +1,488 @@ +mod utils; + +use moon_common::{consts::CONFIG_PROJECT_FILENAME, Id}; +use moon_config2::{ + DependencyScope, FilePath, GlobPath, LanguageType, PlatformType, PortablePath, ProjectConfig, + ProjectDependsOn, ProjectType, TaskCommandArgs, +}; +use rustc_hash::FxHashMap; +use utils::*; + +mod project_config { + use super::*; + + #[test] + #[should_panic( + expected = "unknown field `unknown`, expected one of `$schema`, `dependsOn`, `env`, `fileGroups`, `language`, `platform`, `project`, `tags`, `tasks`, `toolchain`, `type`, `workspace`" + )] + fn error_unknown_field() { + test_load_config(CONFIG_PROJECT_FILENAME, "unknown: 123", |path| { + ProjectConfig::load_from(path, ".") + }); + } + + #[test] + fn loads_defaults() { + let config = test_load_config(CONFIG_PROJECT_FILENAME, "{}", |path| { + ProjectConfig::load_from(path, ".") + }); + + assert_eq!(config.language, LanguageType::Unknown); + assert_eq!(config.type_of, ProjectType::Unknown); + } + + #[test] + fn can_use_references() { + let config = test_load_config( + CONFIG_PROJECT_FILENAME, + r" +tasks: + build: &webpack + command: 'webpack' + inputs: + - 'src/**/*' + start: + <<: *webpack + args: 'serve' +", + |path| ProjectConfig::load_from(path, "."), + ); + + let build = config.tasks.get("build").unwrap(); + + assert_eq!(build.command, TaskCommandArgs::String("webpack".to_owned())); + assert_eq!(build.args, TaskCommandArgs::None); + assert_eq!( + build.inputs, + vec![PortablePath::ProjectGlob(GlobPath("src/**/*".into()))] + ); + + let start = config.tasks.get("start").unwrap(); + + assert_eq!(start.command, TaskCommandArgs::String("webpack".to_owned())); + assert_eq!(start.args, TaskCommandArgs::String("serve".to_owned())); + assert_eq!( + start.inputs, + vec![PortablePath::ProjectGlob(GlobPath("src/**/*".into()))] + ); + } + + // TODO: fix this in schematic? + #[test] + #[should_panic(expected = "unknown field `_webpack`")] + fn can_use_references_from_root() { + let config = test_load_config( + CONFIG_PROJECT_FILENAME, + r" +_webpack: &webpack + command: 'webpack' + inputs: + - 'src/**/*' + +tasks: + build: *webpack + start: + <<: *webpack + args: 'serve' +", + |path| ProjectConfig::load_from(path, "."), + ); + + let build = config.tasks.get("build").unwrap(); + + assert_eq!(build.command, TaskCommandArgs::String("webpack".to_owned())); + assert_eq!(build.args, TaskCommandArgs::None); + assert_eq!( + build.inputs, + vec![PortablePath::ProjectGlob(GlobPath("src/**/*".into()))] + ); + + let start = config.tasks.get("start").unwrap(); + + assert_eq!(start.command, TaskCommandArgs::String("webpack".to_owned())); + assert_eq!(start.args, TaskCommandArgs::String("serve".to_owned())); + assert_eq!( + start.inputs, + vec![PortablePath::ProjectGlob(GlobPath("src/**/*".into()))] + ); + } + + mod depends_on { + use super::*; + + #[test] + fn supports_list_of_strings() { + let config = test_load_config( + CONFIG_PROJECT_FILENAME, + "dependsOn: ['a', 'b', 'c']", + |path| ProjectConfig::load_from(path, "."), + ); + + assert_eq!( + config.depends_on, + vec![ + ProjectDependsOn::String("a".into()), + ProjectDependsOn::String("b".into()), + ProjectDependsOn::String("c".into()) + ] + ); + } + + #[test] + fn supports_list_of_objects() { + let config = test_load_config( + CONFIG_PROJECT_FILENAME, + r" +dependsOn: + - id: 'a' + scope: 'development' + - id: 'b' + scope: 'production'", + |path| ProjectConfig::load_from(path, "."), + ); + + assert_eq!( + config.depends_on, + vec![ + ProjectDependsOn::Object { + id: "a".into(), + scope: DependencyScope::Development, + }, + ProjectDependsOn::Object { + id: "b".into(), + scope: DependencyScope::Production, + } + ] + ); + } + + #[test] + fn supports_list_of_strings_and_objects() { + let config = test_load_config( + CONFIG_PROJECT_FILENAME, + r" +dependsOn: + - 'a' + - id: 'b' + scope: 'production'", + |path| ProjectConfig::load_from(path, "."), + ); + + assert_eq!( + config.depends_on, + vec![ + ProjectDependsOn::String("a".into()), + ProjectDependsOn::Object { + id: "b".into(), + scope: DependencyScope::Production, + } + ] + ); + } + + #[test] + #[should_panic(expected = "expected a project name or dependency config object")] + fn errors_on_invalid_object_scope() { + test_load_config( + CONFIG_PROJECT_FILENAME, + r" +dependsOn: + - id: 'a' + scope: 'invalid' +", + |path| ProjectConfig::load_from(path, "."), + ); + } + } + + mod file_groups { + use super::*; + + #[test] + fn groups_into_correct_enums() { + let config = test_load_config( + CONFIG_PROJECT_FILENAME, + r" +fileGroups: + files: + - /ws/relative + - proj/relative + globs: + - /ws/**/* + - /!ws/**/* + - proj/**/* + - '!proj/**/*' +", + |path| ProjectConfig::load_from(path, "."), + ); + + assert_eq!( + config.file_groups, + FxHashMap::from_iter([ + ( + "files".into(), + vec![ + PortablePath::WorkspaceFile(FilePath("ws/relative".into())), + PortablePath::ProjectFile(FilePath("proj/relative".into())) + ] + ), + ( + "globs".into(), + vec![ + PortablePath::WorkspaceGlob(GlobPath("ws/**/*".into())), + PortablePath::WorkspaceGlob(GlobPath("!ws/**/*".into())), + PortablePath::ProjectGlob(GlobPath("proj/**/*".into())), + PortablePath::ProjectGlob(GlobPath("!proj/**/*".into())), + ] + ), + ]) + ); + } + } + + mod language { + use super::*; + + #[test] + fn supports_variant() { + let config = test_load_config(CONFIG_PROJECT_FILENAME, "language: rust", |path| { + ProjectConfig::load_from(path, ".") + }); + + assert_eq!(config.language, LanguageType::Rust); + } + + #[test] + fn unsupported_variant_becomes_other() { + let config = test_load_config(CONFIG_PROJECT_FILENAME, "language: dotnet", |path| { + ProjectConfig::load_from(path, ".") + }); + + assert_eq!(config.language, LanguageType::Other(Id::raw("dotnet"))); + } + } + + mod platform { + use super::*; + + #[test] + fn supports_variant() { + let config = test_load_config(CONFIG_PROJECT_FILENAME, "platform: rust", |path| { + ProjectConfig::load_from(path, ".") + }); + + assert_eq!(config.platform, Some(PlatformType::Rust)); + } + + #[test] + #[should_panic( + expected = "unknown variant `perl`, expected one of `deno`, `node`, `rust`, `system`, `unknown`" + )] + fn errors_on_invalid_variant() { + test_load_config(CONFIG_PROJECT_FILENAME, "platform: perl", |path| { + ProjectConfig::load_from(path, ".") + }); + } + } + + mod project { + use super::*; + + #[test] + #[should_panic(expected = "must not be empty")] + fn errors_if_empty() { + test_load_config(CONFIG_PROJECT_FILENAME, "project: {}", |path| { + ProjectConfig::load_from(path, ".") + }); + } + + #[test] + fn can_set_only_description() { + let config = test_load_config( + CONFIG_PROJECT_FILENAME, + r" +project: + description: 'Text' +", + |path| ProjectConfig::load_from(path, "."), + ); + + let meta = config.project.unwrap(); + + assert_eq!(meta.description, "Text"); + } + + #[test] + fn can_set_all() { + let config = test_load_config( + CONFIG_PROJECT_FILENAME, + r" +project: + name: Name + description: Description + owner: team + maintainers: [a, b, c] + channel: '#abc' +", + |path| ProjectConfig::load_from(path, "."), + ); + + let meta = config.project.unwrap(); + + assert_eq!(meta.name.unwrap(), "Name"); + assert_eq!(meta.description, "Description"); + assert_eq!(meta.owner.unwrap(), "team"); + assert_eq!(meta.maintainers, vec!["a", "b", "c"]); + assert_eq!(meta.channel.unwrap(), "#abc"); + } + + #[test] + #[should_panic(expected = "must start with a `#`")] + fn errors_if_channel_no_hash() { + test_load_config( + CONFIG_PROJECT_FILENAME, + r" +project: + description: Description + channel: abc +", + |path| ProjectConfig::load_from(path, "."), + ); + } + } + + mod tags { + use super::*; + + #[test] + fn can_set_tags() { + let config = test_load_config( + CONFIG_PROJECT_FILENAME, + r" +tags: + - normal + - camelCase + - kebab-case + - snake_case + - dot.case + - slash/case +", + |path| ProjectConfig::load_from(path, "."), + ); + + assert_eq!( + config.tags, + vec![ + Id::raw("normal"), + Id::raw("camelCase"), + Id::raw("kebab-case"), + Id::raw("snake_case"), + Id::raw("dot.case"), + Id::raw("slash/case") + ] + ); + } + + #[test] + #[should_panic(expected = "Invalid format for foo bar")] + fn errors_on_invalid_format() { + test_load_config(CONFIG_PROJECT_FILENAME, "tags: ['foo bar']", |path| { + ProjectConfig::load_from(path, ".") + }); + } + } + + mod tasks { + use super::*; + + #[test] + fn supports_id_patterns() { + let config = test_load_config( + CONFIG_PROJECT_FILENAME, + r" +tasks: + normal: + command: 'a' + kebab-case: + command: 'b' + camelCase: + command: 'c' + snake_case: + command: 'd' + dot.case: + command: 'e' + slash/case: + command: 'f' +", + |path| ProjectConfig::load_from(path, "."), + ); + + assert!(config.tasks.contains_key("normal")); + assert!(config.tasks.contains_key("kebab-case")); + assert!(config.tasks.contains_key("camelCase")); + assert!(config.tasks.contains_key("snake_case")); + assert!(config.tasks.contains_key("dot.case")); + assert!(config.tasks.contains_key("slash/case")); + } + } + + mod toolchain { + use super::*; + + #[test] + fn can_set_settings() { + let config = test_load_config( + CONFIG_PROJECT_FILENAME, + r" +toolchain: + node: + version: '18.0.0' + typescript: + disabled: false + routeOutDirToCache: true +", + |path| ProjectConfig::load_from(path, "."), + ); + + assert!(config.toolchain.node.is_some()); + assert!(config.toolchain.rust.is_none()); + + assert_eq!( + config.toolchain.node.unwrap().version, + Some("18.0.0".to_string()) + ); + + let ts = config.toolchain.typescript.unwrap(); + + assert!(!ts.disabled); + assert_eq!(ts.route_out_dir_to_cache, Some(true)); + } + } + + mod workspace { + use super::*; + + #[test] + fn can_set_settings() { + let config = test_load_config( + CONFIG_PROJECT_FILENAME, + r" +workspace: + inheritedTasks: + exclude: [a] + include: [b] + rename: + c: d +", + |path| ProjectConfig::load_from(path, "."), + ); + + assert_eq!(config.workspace.inherited_tasks.exclude, vec![Id::raw("a")]); + assert_eq!(config.workspace.inherited_tasks.include, vec![Id::raw("b")]); + assert_eq!( + config.workspace.inherited_tasks.rename, + FxHashMap::from_iter([(Id::raw("c"), Id::raw("d"))]) + ); + } + } +} diff --git a/nextgen/config/tests/task_config_test.rs b/nextgen/config/tests/task_config_test.rs new file mode 100644 index 00000000000..d9443cd78e6 --- /dev/null +++ b/nextgen/config/tests/task_config_test.rs @@ -0,0 +1,516 @@ +mod utils; + +use moon_config2::{ + FilePath, GlobPath, PlatformType, PortablePath, TaskCommandArgs, TaskConfig, TaskMergeStrategy, + TaskOutputStyle, TaskType, +}; +use moon_target::Target; +use utils::*; + +mod task_config { + use super::*; + + #[test] + #[should_panic( + expected = "unknown field `unknown`, expected one of `command`, `args`, `deps`, `env`, `inputs`, `local`, `outputs`, `options`, `platform`, `type`" + )] + fn error_unknown_field() { + test_parse_config("unknown: 123", |code| TaskConfig::parse(code)); + } + + #[test] + fn loads_defaults() { + let config = test_parse_config("{}", |code| TaskConfig::parse(code)); + + assert_eq!(config.command, TaskCommandArgs::None); + assert_eq!(config.args, TaskCommandArgs::None); + assert_eq!(config.type_of, None); + } + + mod command { + use super::*; + + #[test] + #[should_panic(expected = "expected a string or a sequence of strings")] + fn errors_on_invalid_type() { + test_parse_config("command: 123", |code| TaskConfig::parse(code)); + } + + #[test] + #[should_panic(expected = "a command is required; use \"noop\" otherwise")] + fn errors_for_empty_string() { + test_parse_config("command: ''", |code| TaskConfig::parse(code)); + } + + #[test] + #[should_panic(expected = "a command is required; use \"noop\" otherwise")] + fn errors_for_empty_list() { + test_parse_config("command: []", |code| TaskConfig::parse(code)); + } + + #[test] + #[should_panic(expected = "a command is required; use \"noop\" otherwise")] + fn errors_for_empty_list_arg() { + test_parse_config("command: ['']", |code| TaskConfig::parse(code)); + } + + #[test] + fn parses_string() { + let config = test_parse_config("command: bin", |code| TaskConfig::parse(code)); + + assert_eq!(config.command, TaskCommandArgs::String("bin".into())); + } + + #[test] + fn parses_list() { + let config = test_parse_config("command: [bin]", |code| TaskConfig::parse(code)); + + assert_eq!( + config.command, + TaskCommandArgs::Sequence(vec!["bin".into()]) + ); + } + } + + mod args { + use super::*; + + #[test] + fn parses_string() { + let config = test_parse_config("args: bin", |code| TaskConfig::parse(code)); + + assert_eq!(config.args, TaskCommandArgs::String("bin".into())); + } + + #[test] + fn parses_list() { + let config = test_parse_config("args: [bin]", |code| TaskConfig::parse(code)); + + assert_eq!(config.args, TaskCommandArgs::Sequence(vec!["bin".into()])); + } + + #[test] + fn supports_variants() { + let config = test_parse_config( + r" +args: + - arg + - -o + - '@token(0)' + - --opt + - value + - 'quoted arg' +", + |code| TaskConfig::parse(code), + ); + + assert_eq!( + config.args, + TaskCommandArgs::Sequence(vec![ + "arg".into(), + "-o".into(), + "@token(0)".into(), + "--opt".into(), + "value".into(), + "quoted arg".into(), + ]) + ); + } + } + + mod deps { + use super::*; + + #[test] + fn supports_targets() { + let config = test_parse_config( + r" +deps: + - task + - project:task + - ^:task + - ~:task +", + |code| TaskConfig::parse(code), + ); + + assert_eq!( + config.deps, + vec![ + Target::parse("task").unwrap(), + Target::parse("project:task").unwrap(), + Target::parse("^:task").unwrap(), + Target::parse("~:task").unwrap() + ] + ); + } + + #[test] + #[should_panic(expected = "Invalid target ~:bad target")] + fn errors_on_invalid_format() { + test_parse_config("deps: ['bad target']", |code| TaskConfig::parse(code)); + } + + #[test] + #[should_panic(expected = "target scope not supported as a task dependency")] + fn errors_on_all_scope() { + test_parse_config("deps: [':task']", |code| TaskConfig::parse(code)); + } + + #[test] + #[should_panic(expected = "target scope not supported as a task dependency")] + fn errors_on_tag_scope() { + test_parse_config("deps: ['#tag:task']", |code| TaskConfig::parse(code)); + } + } + + mod inputs { + use super::*; + + #[test] + fn supports_path_patterns() { + let config = test_parse_config( + r" +inputs: + - /ws/path + - '/ws/glob/**/*' + - '/!ws/glob/**/*' + - proj/path + - 'proj/glob/{a,b,c}' + - '!proj/glob/{a,b,c}' +", + |code| TaskConfig::parse(code), + ); + + assert_eq!( + config.inputs, + vec![ + PortablePath::WorkspaceFile(FilePath("ws/path".into())), + PortablePath::WorkspaceGlob(GlobPath("ws/glob/**/*".into())), + PortablePath::WorkspaceGlob(GlobPath("!ws/glob/**/*".into())), + PortablePath::ProjectFile(FilePath("proj/path".into())), + PortablePath::ProjectGlob(GlobPath("proj/glob/{a,b,c}".into())), + PortablePath::ProjectGlob(GlobPath("!proj/glob/{a,b,c}".into())), + ] + ); + } + + #[test] + fn supports_env_vars() { + let config = test_parse_config( + r" +inputs: + - $FOO_BAR + - file/path +", + |code| TaskConfig::parse(code), + ); + + assert_eq!( + config.inputs, + vec![ + PortablePath::EnvVar("FOO_BAR".into()), + PortablePath::ProjectFile(FilePath("file/path".into())), + ] + ); + } + } + + mod outputs { + use super::*; + + #[test] + fn supports_path_patterns() { + let config = test_parse_config( + r" +outputs: + - /ws/path + - '/ws/glob/**/*' + - '/!ws/glob/**/*' + - proj/path + - 'proj/glob/{a,b,c}' + - '!proj/glob/{a,b,c}' +", + |code| TaskConfig::parse(code), + ); + + assert_eq!( + config.outputs, + vec![ + PortablePath::WorkspaceFile(FilePath("ws/path".into())), + PortablePath::WorkspaceGlob(GlobPath("ws/glob/**/*".into())), + PortablePath::WorkspaceGlob(GlobPath("!ws/glob/**/*".into())), + PortablePath::ProjectFile(FilePath("proj/path".into())), + PortablePath::ProjectGlob(GlobPath("proj/glob/{a,b,c}".into())), + PortablePath::ProjectGlob(GlobPath("!proj/glob/{a,b,c}".into())), + ] + ); + } + + #[test] + #[should_panic(expected = "environment variables are not supported here")] + fn errors_on_env_var() { + test_parse_config( + r" +outputs: + - $FOO_BAR + - file/path +", + |code| TaskConfig::parse(code), + ); + } + } + + mod platform { + use super::*; + + #[test] + fn supports_variant() { + let config = test_parse_config("platform: rust", |code| TaskConfig::parse(code)); + + assert_eq!(config.platform, PlatformType::Rust); + } + + #[test] + #[should_panic( + expected = "unknown variant `perl`, expected one of `deno`, `node`, `rust`, `system`, `unknown`" + )] + fn errors_on_invalid_variant() { + test_parse_config("platform: perl", |code| TaskConfig::parse(code)); + } + } + + mod type_of { + use super::*; + + #[test] + fn supports_variant() { + let config = test_parse_config("type: build", |code| TaskConfig::parse(code)); + + assert_eq!(config.type_of, Some(TaskType::Build)); + } + + #[test] + #[should_panic( + expected = "unknown variant `cache`, expected one of `build`, `run`, `test`" + )] + fn errors_on_invalid_variant() { + test_parse_config("type: cache", |code| TaskConfig::parse(code)); + } + } + + mod options { + use super::*; + + #[test] + fn loads_defaults() { + let config = test_parse_config("{}", |code| TaskConfig::parse(code)); + let opts = config.options; + + assert!(opts.cache); + assert!(opts.run_deps_in_parallel); + assert!(!opts.run_from_workspace_root); + assert!(opts.shell); + assert_eq!(opts.affected_files, None); + assert_eq!(opts.env_file, None); + } + + #[test] + fn can_set_options() { + let config = test_parse_config( + r" +options: + cache: false + runDepsInParallel: false + mergeDeps: replace + outputStyle: stream +", + |code| TaskConfig::parse(code), + ); + let opts = config.options; + + assert!(!opts.cache); + assert!(!opts.run_deps_in_parallel); + assert_eq!(opts.merge_deps, TaskMergeStrategy::Replace); + assert_eq!(opts.output_style, TaskOutputStyle::Stream); + } + + mod affected_files { + use super::*; + use moon_config2::TaskOptionAffectedFiles; + + #[test] + fn can_use_true() { + let config = test_parse_config( + r" +options: + affectedFiles: true +", + |code| TaskConfig::parse(code), + ); + + assert_eq!( + config.options.affected_files, + Some(TaskOptionAffectedFiles::Enabled(true)) + ); + } + + #[test] + fn can_use_false() { + let config = test_parse_config( + r" +options: + affectedFiles: false +", + |code| TaskConfig::parse(code), + ); + + assert_eq!( + config.options.affected_files, + Some(TaskOptionAffectedFiles::Enabled(false)) + ); + } + + #[test] + fn can_set_args() { + let config = test_parse_config( + r" +options: + affectedFiles: args +", + |code| TaskConfig::parse(code), + ); + + assert_eq!( + config.options.affected_files, + Some(TaskOptionAffectedFiles::Args) + ); + } + + #[test] + fn can_set_env() { + let config = test_parse_config( + r" +options: + affectedFiles: env +", + |code| TaskConfig::parse(code), + ); + + assert_eq!( + config.options.affected_files, + Some(TaskOptionAffectedFiles::Env) + ); + } + + #[test] + #[should_panic(expected = "expected `args`, `env`, or a boolean")] + fn errors_on_invalid_variant() { + test_parse_config( + r" +options: + affectedFiles: other +", + |code| TaskConfig::parse(code), + ); + } + } + + mod env_file { + use super::*; + use moon_config2::TaskOptionEnvFile; + + #[test] + fn can_use_true() { + let config = test_parse_config( + r" +options: + envFile: true +", + |code| TaskConfig::parse(code), + ); + + assert_eq!( + config.options.env_file, + Some(TaskOptionEnvFile::Enabled(true)) + ); + } + + #[test] + fn can_use_false() { + let config = test_parse_config( + r" +options: + envFile: false +", + |code| TaskConfig::parse(code), + ); + + assert_eq!( + config.options.env_file, + Some(TaskOptionEnvFile::Enabled(false)) + ); + } + + #[test] + fn can_set_project_path() { + let config = test_parse_config( + r" +options: + envFile: .env.file +", + |code| TaskConfig::parse(code), + ); + + assert_eq!( + config.options.env_file, + Some(TaskOptionEnvFile::File(PortablePath::ProjectFile( + FilePath(".env.file".into()) + ))) + ); + } + + #[test] + fn can_set_workspace_path() { + let config = test_parse_config( + r" +options: + envFile: /.env.file +", + |code| TaskConfig::parse(code), + ); + + assert_eq!( + config.options.env_file, + Some(TaskOptionEnvFile::File(PortablePath::WorkspaceFile( + FilePath(".env.file".into()) + ))) + ); + } + + #[test] + #[should_panic(expected = "globs are not supported")] + fn errors_on_glob() { + test_parse_config( + r" +options: + envFile: .env.* +", + |code| TaskConfig::parse(code), + ); + } + + #[test] + #[should_panic(expected = "environment variables are not supported")] + fn errors_on_env_var() { + test_parse_config( + r" +options: + envFile: $ENV_VAR +", + |code| TaskConfig::parse(code), + ); + } + } + } +} diff --git a/nextgen/config/tests/template_config_test.rs b/nextgen/config/tests/template_config_test.rs index eb24e8d9621..3f127ba625c 100644 --- a/nextgen/config/tests/template_config_test.rs +++ b/nextgen/config/tests/template_config_test.rs @@ -14,7 +14,7 @@ mod template_config { )] fn error_unknown_field() { test_load_config(CONFIG_TEMPLATE_FILENAME, "unknown: 123", |path| { - TemplateConfig::load_from(path) + TemplateConfig::load_from(path, path) }); } @@ -23,7 +23,7 @@ mod template_config { let config = test_load_config( CONFIG_TEMPLATE_FILENAME, "title: title\ndescription: description", - |path| TemplateConfig::load_from(path), + |path| TemplateConfig::load_from(path, path), ); assert_eq!(config.title, "title"); @@ -38,7 +38,7 @@ mod template_config { #[should_panic(expected = "invalid type: integer `123`, expected a string")] fn invalid_type() { test_load_config(CONFIG_TEMPLATE_FILENAME, "title: 123", |path| { - TemplateConfig::load_from(path) + TemplateConfig::load_from(path, path) }); } @@ -48,7 +48,7 @@ mod template_config { test_load_config( CONFIG_TEMPLATE_FILENAME, "title: ''\ndescription: 'asd'", - |path| TemplateConfig::load_from(path), + |path| TemplateConfig::load_from(path, path), ); } } @@ -60,7 +60,7 @@ mod template_config { #[should_panic(expected = "invalid type: integer `123`, expected a string")] fn invalid_type() { test_load_config(CONFIG_TEMPLATE_FILENAME, "description: 123", |path| { - TemplateConfig::load_from(path) + TemplateConfig::load_from(path, path) }); } @@ -70,7 +70,7 @@ mod template_config { test_load_config( CONFIG_TEMPLATE_FILENAME, "title: 'asd'\ndescription: ''", - |path| TemplateConfig::load_from(path), + |path| TemplateConfig::load_from(path, path), ); } } @@ -96,7 +96,7 @@ variables: unknown: type: array ", - |path| TemplateConfig::load_from(path), + |path| TemplateConfig::load_from(path, path), ); } @@ -114,7 +114,7 @@ variables: prompt: prompt required: true ", - |path| TemplateConfig::load_from(path), + |path| TemplateConfig::load_from(path, path), ); assert_eq!( @@ -140,7 +140,7 @@ variables: type: boolean default: 123 ", - |path| TemplateConfig::load_from(path), + |path| TemplateConfig::load_from(path, path), ); } @@ -158,7 +158,7 @@ variables: prompt: prompt required: false ", - |path| TemplateConfig::load_from(path), + |path| TemplateConfig::load_from(path, path), ); assert_eq!( @@ -184,7 +184,7 @@ variables: type: number default: true ", - |path| TemplateConfig::load_from(path), + |path| TemplateConfig::load_from(path, path), ); } @@ -200,7 +200,7 @@ variables: type: string default: abc ", - |path| TemplateConfig::load_from(path), + |path| TemplateConfig::load_from(path, path), ); assert_eq!( @@ -226,7 +226,7 @@ variables: type: string default: 123 ", - |path| TemplateConfig::load_from(path), + |path| TemplateConfig::load_from(path, path), ); } @@ -248,7 +248,7 @@ variables: value: c prompt: prompt ", - |path| TemplateConfig::load_from(path), + |path| TemplateConfig::load_from(path, path), ); assert_eq!( @@ -284,7 +284,7 @@ variables: values: [1, 2, 3] prompt: prompt ", - |path| TemplateConfig::load_from(path), + |path| TemplateConfig::load_from(path, path), ); } } diff --git a/nextgen/config/tests/toolchain_config_test.rs b/nextgen/config/tests/toolchain_config_test.rs index 2bd1ec70275..2543d64e708 100644 --- a/nextgen/config/tests/toolchain_config_test.rs +++ b/nextgen/config/tests/toolchain_config_test.rs @@ -2,7 +2,7 @@ mod utils; use moon_config2::{FilePath, ToolchainConfig}; use proto::ToolsConfig; -// use std::env; +use std::env; use utils::*; const FILENAME: &str = ".moon/toolchain.yml"; @@ -79,6 +79,289 @@ deno: } } + mod node { + use super::*; + + #[test] + fn uses_defaults() { + let config = test_load_config(FILENAME, "node: {}", |path| { + ToolchainConfig::load_from(path, &ToolsConfig::default()) + }); + + let cfg = config.node.unwrap(); + + assert!(cfg.dedupe_on_lockfile_change); + assert!(!cfg.infer_tasks_from_scripts); + } + + #[test] + fn sets_values() { + let config = test_load_config( + FILENAME, + r" +node: + dedupeOnLockfileChange: false + inferTasksFromScripts: true +", + |path| ToolchainConfig::load_from(path, &ToolsConfig::default()), + ); + + let cfg = config.node.unwrap(); + + assert!(!cfg.dedupe_on_lockfile_change); + assert!(cfg.infer_tasks_from_scripts); + } + + #[test] + fn enables_via_proto() { + let config = test_load_config(FILENAME, "{}", |path| { + let mut proto = ToolsConfig::default(); + proto.tools.insert("node".into(), "18.0.0".into()); + + ToolchainConfig::load_from(path, &proto) + }); + + assert!(config.node.is_some()); + assert_eq!(config.node.unwrap().version.unwrap(), "18.0.0"); + } + + #[test] + fn proto_version_doesnt_override() { + let config = test_load_config( + FILENAME, + r" +node: + version: 20.0.0 +", + |path| { + let mut proto = ToolsConfig::default(); + proto.tools.insert("node".into(), "18.0.0".into()); + + ToolchainConfig::load_from(path, &proto) + }, + ); + + assert!(config.node.is_some()); + assert_eq!(config.node.unwrap().version.unwrap(), "20.0.0"); + } + + #[test] + #[should_panic(expected = "not a valid semantic version")] + fn validates_version() { + test_load_config( + FILENAME, + r" +node: + version: '1' +", + |path| ToolchainConfig::load_from(path, &ToolsConfig::default()), + ); + } + + #[test] + fn inherits_version_from_env_var() { + env::set_var("MOON_NODE_VERSION", "19.0.0"); + + let config = test_load_config( + FILENAME, + r" +node: + version: 20.0.0 +", + |path| { + let mut proto = ToolsConfig::default(); + proto.tools.insert("node".into(), "18.0.0".into()); + + ToolchainConfig::load_from(path, &proto) + }, + ); + + env::remove_var("MOON_NODE_VERSION"); + + assert_eq!(config.node.unwrap().version.unwrap(), "19.0.0"); + } + + mod npm { + use super::*; + + #[test] + fn proto_version_doesnt_override() { + let config = test_load_config( + FILENAME, + r" +node: + npm: + version: 9.0.0 +", + |path| { + let mut proto = ToolsConfig::default(); + proto.tools.insert("npm".into(), "8.0.0".into()); + + ToolchainConfig::load_from(path, &proto) + }, + ); + + assert_eq!(config.node.unwrap().npm.version.unwrap(), "9.0.0"); + } + + #[test] + fn inherits_version_from_env_var() { + env::set_var("MOON_NPM_VERSION", "10.0.0"); + + let config = test_load_config( + FILENAME, + r" +node: + npm: + version: 9.0.0 +", + |path| { + let mut proto = ToolsConfig::default(); + proto.tools.insert("npm".into(), "8.0.0".into()); + + ToolchainConfig::load_from(path, &proto) + }, + ); + + env::remove_var("MOON_NPM_VERSION"); + + assert_eq!(config.node.unwrap().npm.version.unwrap(), "10.0.0"); + } + } + + mod pnpm { + use super::*; + + #[test] + fn enables_when_defined() { + let config = test_load_config(FILENAME, "node: {}", |path| { + ToolchainConfig::load_from(path, &ToolsConfig::default()) + }); + + assert!(config.node.unwrap().pnpm.is_none()); + + let config = test_load_config(FILENAME, "node:\n pnpm: {}", |path| { + ToolchainConfig::load_from(path, &ToolsConfig::default()) + }); + + assert!(config.node.unwrap().pnpm.is_some()); + } + + #[test] + fn proto_version_doesnt_override() { + let config = test_load_config( + FILENAME, + r" +node: + pnpm: + version: 9.0.0 +", + |path| { + let mut proto = ToolsConfig::default(); + proto.tools.insert("pnpm".into(), "8.0.0".into()); + + ToolchainConfig::load_from(path, &proto) + }, + ); + + assert_eq!(config.node.unwrap().pnpm.unwrap().version.unwrap(), "9.0.0"); + } + + #[test] + fn inherits_version_from_env_var() { + env::set_var("MOON_PNPM_VERSION", "10.0.0"); + + let config = test_load_config( + FILENAME, + r" +node: + pnpm: + version: 9.0.0 +", + |path| { + let mut proto = ToolsConfig::default(); + proto.tools.insert("pnpm".into(), "8.0.0".into()); + + ToolchainConfig::load_from(path, &proto) + }, + ); + + env::remove_var("MOON_PNPM_VERSION"); + + assert_eq!( + config.node.unwrap().pnpm.unwrap().version.unwrap(), + "10.0.0" + ); + } + } + + mod yarn { + use super::*; + + #[test] + fn enables_when_defined() { + let config = test_load_config(FILENAME, "node: {}", |path| { + ToolchainConfig::load_from(path, &ToolsConfig::default()) + }); + + assert!(config.node.unwrap().yarn.is_none()); + + let config = test_load_config(FILENAME, "node:\n yarn: {}", |path| { + ToolchainConfig::load_from(path, &ToolsConfig::default()) + }); + + assert!(config.node.unwrap().yarn.is_some()); + } + + #[test] + fn proto_version_doesnt_override() { + let config = test_load_config( + FILENAME, + r" +node: + yarn: + version: 9.0.0 +", + |path| { + let mut proto = ToolsConfig::default(); + proto.tools.insert("yarn".into(), "8.0.0".into()); + + ToolchainConfig::load_from(path, &proto) + }, + ); + + assert_eq!(config.node.unwrap().yarn.unwrap().version.unwrap(), "9.0.0"); + } + + #[test] + fn inherits_version_from_env_var() { + env::set_var("MOON_YARN_VERSION", "10.0.0"); + + let config = test_load_config( + FILENAME, + r" +node: + yarn: + version: 9.0.0 +", + |path| { + let mut proto = ToolsConfig::default(); + proto.tools.insert("yarn".into(), "8.0.0".into()); + + ToolchainConfig::load_from(path, &proto) + }, + ); + + env::remove_var("MOON_YARN_VERSION"); + + assert_eq!( + config.node.unwrap().yarn.unwrap().version.unwrap(), + "10.0.0" + ); + } + } + } + mod rust { use super::*; @@ -158,28 +441,28 @@ rust: ); } - // #[test] - // fn inherits_version_from_env_var() { - // env::set_var("MOON_RUST_VERSION", "1.70.0"); + #[test] + fn inherits_version_from_env_var() { + env::set_var("MOON_RUST_VERSION", "1.70.0"); - // let config = test_load_config( - // FILENAME, - // r" - // rust: - // version: 1.60.0 - // ", - // |path| { - // let mut proto = ToolsConfig::default(); - // proto.tools.insert("rust".into(), "1.65.0".into()); + let config = test_load_config( + FILENAME, + r" + rust: + version: 1.60.0 + ", + |path| { + let mut proto = ToolsConfig::default(); + proto.tools.insert("rust".into(), "1.65.0".into()); - // ToolchainConfig::load_from(path, &proto) - // }, - // ); + ToolchainConfig::load_from(path, &proto) + }, + ); - // env::remove_var("MOON_RUST_VERSION"); + env::remove_var("MOON_RUST_VERSION"); - // assert_eq!(config.rust.unwrap().version.unwrap(), "1.70.0"); - // } + assert_eq!(config.rust.unwrap().version.unwrap(), "1.70.0"); + } } mod typescript { diff --git a/nextgen/config/tests/workspace_config_test.rs b/nextgen/config/tests/workspace_config_test.rs new file mode 100644 index 00000000000..cf505372585 --- /dev/null +++ b/nextgen/config/tests/workspace_config_test.rs @@ -0,0 +1,517 @@ +mod utils; + +use moon_config2::{FilePath, WorkspaceConfig, WorkspaceProjects}; +use rustc_hash::FxHashMap; +use utils::*; + +const FILENAME: &str = ".moon/workspace.yml"; + +mod workspace_config { + use super::*; + + #[test] + #[should_panic( + expected = "unknown field `unknown`, expected one of `$schema`, `constraints`, `extends`, `generator`, `hasher`, `notifier`, `projects`, `runner`, `telemetry`, `vcs`, `versionConstraint`" + )] + fn error_unknown_field() { + test_load_config(FILENAME, "unknown: 123", |path| { + WorkspaceConfig::load_from(path) + }); + } + + #[test] + fn loads_defaults() { + let config = test_load_config(FILENAME, "{}", |path| WorkspaceConfig::load_from(path)); + + assert!(config.telemetry); + assert!(config.version_constraint.is_none()); + } + + mod projects { + use super::*; + + #[test] + fn supports_sources() { + let config = test_load_config( + FILENAME, + r" +projects: + app: apps/app + foo-kebab: ./packages/foo + barCamel: packages/bar + baz_snake: ./packages/baz + qux.dot: packages/qux + wat/slash: ./packages/wat +", + |path| WorkspaceConfig::load_from(path), + ); + + assert_eq!( + config.projects, + WorkspaceProjects::Sources(FxHashMap::from_iter([ + ("app".into(), "apps/app".into()), + ("foo-kebab".into(), "./packages/foo".into()), + ("barCamel".into(), "packages/bar".into()), + ("baz_snake".into(), "./packages/baz".into()), + ("qux.dot".into(), "packages/qux".into()), + ("wat/slash".into(), "./packages/wat".into()) + ])), + ); + } + + #[test] + #[should_panic(expected = "absolute paths are not supported")] + fn errors_on_absolute_sources() { + test_load_config( + FILENAME, + r" +projects: + app: /apps/app +", + |path| WorkspaceConfig::load_from(path), + ); + } + + #[test] + #[should_panic(expected = "parent relative paths are not supported")] + fn errors_on_parent_sources() { + test_load_config( + FILENAME, + r" +projects: + app: ../apps/app +", + |path| WorkspaceConfig::load_from(path), + ); + } + + #[test] + #[should_panic(expected = "globs are not supported, expected a literal file path")] + fn errors_on_glob_in_sources() { + test_load_config( + FILENAME, + r" +projects: + app: apps/app/* +", + |path| WorkspaceConfig::load_from(path), + ); + } + + #[test] + fn supports_globs() { + let config = test_load_config( + FILENAME, + r" +projects: + - apps/* + - packages/* + - internal +", + |path| WorkspaceConfig::load_from(path), + ); + + assert_eq!( + config.projects, + WorkspaceProjects::Globs(vec![ + "apps/*".into(), + "packages/*".into(), + "internal".into(), + ]), + ); + } + + #[test] + #[should_panic(expected = "absolute paths are not supported")] + fn errors_on_absolute_globs() { + test_load_config( + FILENAME, + r" +projects: + - /apps/* +", + |path| WorkspaceConfig::load_from(path), + ); + } + + #[test] + #[should_panic(expected = "parent relative paths are not supported")] + fn errors_on_parent_globs() { + test_load_config( + FILENAME, + r" +projects: + - ../apps/* +", + |path| WorkspaceConfig::load_from(path), + ); + } + + #[test] + fn supports_globs_and_projects() { + let config = test_load_config( + FILENAME, + r" +projects: + sources: + app: app + globs: + - packages/* +", + |path| WorkspaceConfig::load_from(path), + ); + + assert_eq!( + config.projects, + WorkspaceProjects::Both { + globs: vec!["packages/*".into()], + sources: FxHashMap::from_iter([("app".into(), "app".into())]) + }, + ); + } + } + + mod constraints { + use super::*; + + #[test] + fn loads_defaults() { + let config = test_load_config(FILENAME, "constraints: {}", |path| { + WorkspaceConfig::load_from(path) + }); + + assert!(config.constraints.enforce_project_type_relationships); + assert!(config.constraints.tag_relationships.is_empty()); + } + + #[test] + fn can_set_tags() { + let config = test_load_config( + FILENAME, + r" +constraints: + tagRelationships: + id: ['other'] +", + |path| WorkspaceConfig::load_from(path), + ); + + assert!(config.constraints.enforce_project_type_relationships); + assert_eq!( + config.constraints.tag_relationships, + FxHashMap::from_iter([("id".into(), vec!["other".into()])]) + ); + } + + #[test] + #[should_panic( + expected = "invalid type: integer `123`, expected struct PartialConstraintsConfig" + )] + fn errors_on_invalid_type() { + test_load_config(FILENAME, "constraints: 123", |path| { + WorkspaceConfig::load_from(path) + }); + } + + #[test] + #[should_panic(expected = "invalid type: string \"abc\", expected a boolean")] + fn errors_on_invalid_setting_type() { + test_load_config( + FILENAME, + r" +constraints: + enforceProjectTypeRelationships: abc +", + |path| WorkspaceConfig::load_from(path), + ); + } + + #[test] + #[should_panic(expected = "Invalid format for bad id")] + fn errors_on_invalid_tag_format() { + test_load_config( + FILENAME, + r" +constraints: + tagRelationships: + id: ['bad id'] +", + |path| WorkspaceConfig::load_from(path), + ); + } + } + + mod generator { + use super::*; + + #[test] + fn loads_defaults() { + let config = test_load_config(FILENAME, "generator: {}", |path| { + WorkspaceConfig::load_from(path) + }); + + assert_eq!( + config.generator.templates, + vec![FilePath("./templates".into())] + ); + } + + #[test] + fn can_set_templates() { + let config = test_load_config( + FILENAME, + r" +generator: + templates: + - custom/path + - ./rel/path + - ../parent/path + - /abs/path +", + |path| WorkspaceConfig::load_from(path), + ); + + assert_eq!( + config.generator.templates, + vec![ + FilePath("custom/path".into()), + FilePath("./rel/path".into()), + FilePath("../parent/path".into()), + FilePath("/abs/path".into()) + ] + ); + } + + #[test] + #[should_panic(expected = "globs are not supported, expected a literal file path")] + fn errors_on_template_glob() { + test_load_config( + FILENAME, + r" +generator: + templates: ['glob/**/*'] +", + |path| WorkspaceConfig::load_from(path), + ); + } + } + + mod hasher { + use super::*; + + #[test] + fn loads_defaults() { + let config = test_load_config(FILENAME, "hasher: {}", |path| { + WorkspaceConfig::load_from(path) + }); + + assert_eq!(config.hasher.batch_size, 2500); + assert!(config.hasher.warn_on_missing_inputs); + } + + #[test] + fn can_set_settings() { + let config = test_load_config( + FILENAME, + r" +hasher: + batchSize: 1000 + warnOnMissingInputs: false +", + |path| WorkspaceConfig::load_from(path), + ); + + assert_eq!(config.hasher.batch_size, 1000); + assert!(!config.hasher.warn_on_missing_inputs); + } + + #[test] + #[should_panic(expected = "unknown variant `unknown`, expected `glob` or `vcs`")] + fn errors_on_invalid_variant() { + test_load_config( + FILENAME, + r" +hasher: + walkStrategy: unknown +", + |path| WorkspaceConfig::load_from(path), + ); + } + } + + mod notifier { + use super::*; + + #[test] + fn loads_defaults() { + let config = test_load_config(FILENAME, "notifier: {}", |path| { + WorkspaceConfig::load_from(path) + }); + + assert!(config.notifier.webhook_url.is_none()); + } + + #[test] + fn can_set_settings() { + let config = test_load_config( + FILENAME, + r" +notifier: + webhookUrl: 'https://domain.com/some/url' +", + |path| WorkspaceConfig::load_from(path), + ); + + assert_eq!( + config.notifier.webhook_url, + Some("https://domain.com/some/url".into()) + ); + } + + #[test] + #[should_panic(expected = "not a valid url: relative URL without a base")] + fn errors_on_invalid_url() { + test_load_config( + FILENAME, + r" +notifier: + webhookUrl: 'invalid value' +", + |path| WorkspaceConfig::load_from(path), + ); + } + + #[test] + #[should_panic(expected = "only secure URLs are allowed")] + fn errors_on_non_https_url() { + test_load_config( + FILENAME, + r" +notifier: + webhookUrl: 'http://domain.com/some/url' +", + |path| WorkspaceConfig::load_from(path), + ); + } + } + + mod runner { + use super::*; + use moon_target::Target; + + #[test] + fn loads_defaults() { + let config = test_load_config(FILENAME, "runner: {}", |path| { + WorkspaceConfig::load_from(path) + }); + + assert_eq!(config.runner.cache_lifetime, "7 days"); + assert!(config.runner.inherit_colors_for_piped_tasks); + } + + #[test] + fn can_set_settings() { + let config = test_load_config( + FILENAME, + r" +runner: + cacheLifetime: 10 hours + inheritColorsForPipedTasks: false +", + |path| WorkspaceConfig::load_from(path), + ); + + assert_eq!(config.runner.cache_lifetime, "10 hours"); + assert!(!config.runner.inherit_colors_for_piped_tasks); + } + + #[test] + fn can_use_targets() { + let config = test_load_config( + FILENAME, + r" +runner: + archivableTargets: ['scope:task'] +", + |path| WorkspaceConfig::load_from(path), + ); + + assert_eq!( + config.runner.archivable_targets, + vec![Target::new("scope", "task").unwrap()] + ); + } + + #[test] + #[should_panic(expected = "Invalid target ~:bad target")] + fn errors_on_invalid_target() { + test_load_config( + FILENAME, + r" +runner: + archivableTargets: ['bad target'] +", + |path| WorkspaceConfig::load_from(path), + ); + } + } + + mod vcs { + use super::*; + + #[test] + fn loads_defaults() { + let config = + test_load_config(FILENAME, "vcs: {}", |path| WorkspaceConfig::load_from(path)); + + assert_eq!(config.vcs.default_branch, "master"); + assert_eq!( + config.vcs.remote_candidates, + vec!["origin".to_string(), "upstream".to_string()] + ); + } + + #[test] + fn can_set_settings() { + let config = test_load_config( + FILENAME, + r" +vcs: + defaultBranch: main + remoteCandidates: [next] +", + |path| WorkspaceConfig::load_from(path), + ); + + assert_eq!(config.vcs.default_branch, "main"); + assert_eq!(config.vcs.remote_candidates, vec!["next".to_string()]); + } + + #[test] + #[should_panic(expected = "unknown variant `mercurial`, expected `git` or `svn`")] + fn errors_on_invalid_manager() { + test_load_config( + FILENAME, + r" +vcs: + manager: mercurial +", + |path| WorkspaceConfig::load_from(path), + ); + } + } + + mod version_constraint { + use super::*; + + #[test] + #[should_panic( + expected = "doesn't meet semantic version requirements: unexpected character '@' while parsing major version number" + )] + fn errors_on_invalid_req() { + test_load_config(FILENAME, "versionConstraint: '@1.0.0'", |path| { + WorkspaceConfig::load_from(path) + }); + } + } +} diff --git a/nextgen/target/src/target.rs b/nextgen/target/src/target.rs index 4a8fb3fd4d7..667e42becec 100644 --- a/nextgen/target/src/target.rs +++ b/nextgen/target/src/target.rs @@ -28,20 +28,22 @@ pub struct Target { } impl Target { - pub fn new(project_id: S, task_id: T) -> Result + pub fn new(scope_id: S, task_id: T) -> Result where S: AsRef, T: AsRef, { - let project_id = project_id.as_ref(); + let scope_id = scope_id.as_ref(); let task_id = task_id.as_ref(); - let scope = TargetScope::Project(Id::new(project_id)?); + + let handle_error = |_| TargetError::InvalidFormat(format!("{scope_id}:{task_id}")); + let scope = TargetScope::Project(Id::new(scope_id).map_err(handle_error)?); Ok(Target { id: Target::format(&scope, task_id)?, scope, - scope_id: Some(Id::raw(project_id)), - task_id: Id::new(task_id)?, + scope_id: Some(Id::raw(scope_id)), + task_id: Id::new(task_id).map_err(handle_error)?, }) } @@ -55,7 +57,8 @@ impl Target { id: Target::format(TargetScope::OwnSelf, task_id)?, scope: TargetScope::OwnSelf, scope_id: None, - task_id: Id::new(task_id)?, + task_id: Id::new(task_id) + .map_err(|_| TargetError::InvalidFormat(format!("~:{task_id}")))?, }) } @@ -80,6 +83,8 @@ impl Target { return Err(TargetError::InvalidFormat(target_id.to_owned())); }; + let handle_error = |_| TargetError::InvalidFormat(target_id.to_owned()); + let mut scope_id = None; let scope = match matches.name("scope") { Some(value) => match value.as_str() { @@ -88,10 +93,10 @@ impl Target { "~" => TargetScope::OwnSelf, id => { if let Some(tag) = id.strip_prefix('#') { - scope_id = Some(Id::raw(tag)); + scope_id = Some(Id::new(tag).map_err(handle_error)?); TargetScope::Tag(Id::raw(tag)) } else { - scope_id = Some(Id::raw(id)); + scope_id = Some(Id::new(id).map_err(handle_error)?); TargetScope::Project(Id::raw(id)) } } @@ -99,7 +104,7 @@ impl Target { None => TargetScope::All, }; - let task_id = Id::raw(matches.name("task").unwrap().as_str()); + let task_id = Id::new(matches.name("task").unwrap().as_str()).map_err(handle_error)?; Ok(Target { id: target_id.to_owned(), diff --git a/nextgen/target/src/target_error.rs b/nextgen/target/src/target_error.rs index de3fa4be028..6037272cb6b 100644 --- a/nextgen/target/src/target_error.rs +++ b/nextgen/target/src/target_error.rs @@ -3,7 +3,7 @@ use moon_common::{Diagnostic, Error, IdError, Style, Stylize}; #[derive(Error, Debug)] pub enum TargetError { #[error( - "Invalid target {}, must be in the format of \"scope:task\".", .0.style(Style::Label) + "Invalid target {}, must be in the format of \"scope:task\", with acceptable identifier characters.", .0.style(Style::Label) )] InvalidFormat(String),