Skip to content

Commit

Permalink
new: Add .env support with the envFile task option. (#251)
Browse files Browse the repository at this point in the history
* Add dotenv.

* Add validation and error handler.

* Use an enum.

* Add test.

* Rename struct.

* Add tests.

* Fix test.
  • Loading branch information
milesj committed Aug 14, 2022
1 parent e248c52 commit 793cfb0
Show file tree
Hide file tree
Showing 15 changed files with 443 additions and 81 deletions.
10 changes: 10 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

75 changes: 74 additions & 1 deletion crates/config/src/project/task.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
use crate::project::{ProjectConfig, ProjectLanguage};
use crate::types::{FilePath, InputValue, TargetID};
use crate::validators::{skip_if_default, validate_child_or_root_path, validate_target};
use crate::validators::{
skip_if_default, validate_child_or_root_path, validate_child_relative_path, validate_target,
};
use moon_utils::process::split_args;
use moon_utils::regex::{ENV_VAR, NODE_COMMAND, UNIX_SYSTEM_COMMAND, WINDOWS_SYSTEM_COMMAND};
use schemars::gen::SchemaGenerator;
Expand Down Expand Up @@ -42,6 +44,14 @@ fn validate_outputs(list: &[String]) -> Result<(), ValidationError> {
Ok(())
}

fn validate_env_file(file: &TaskOptionEnvFile) -> Result<(), ValidationError> {
if let TaskOptionEnvFile::File(path) = file {
validate_child_relative_path("env_file", path)?;
}

Ok(())
}

#[derive(Clone, Debug, Default, Deserialize, Display, JsonSchema, PartialEq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum PlatformType {
Expand All @@ -56,6 +66,23 @@ pub enum PlatformType {
Unknown,
}

#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
#[serde(untagged)]
pub enum TaskOptionEnvFile {
Enabled(bool),
File(String),
}

impl TaskOptionEnvFile {
pub fn to_option(&self) -> Option<String> {
match self {
TaskOptionEnvFile::Enabled(true) => Some(".env".to_owned()),
TaskOptionEnvFile::Enabled(false) => None,
TaskOptionEnvFile::File(path) => Some(path.to_owned()),
}
}
}

#[derive(Clone, Debug, Default, Deserialize, JsonSchema, PartialEq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum TaskMergeStrategy {
Expand All @@ -78,6 +105,10 @@ pub struct TaskOptionsConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub cache: Option<bool>,

#[serde(skip_serializing_if = "Option::is_none")]
#[validate(custom = "validate_env_file")]
pub env_file: Option<TaskOptionEnvFile>,

#[serde(skip_serializing_if = "Option::is_none")]
pub merge_args: Option<TaskMergeStrategy>,

Expand Down Expand Up @@ -674,5 +705,47 @@ options:
Ok(())
});
}

#[test]
#[should_panic(
expected = "data did not match any variant of untagged enum TaskOptionEnvFile for key \"default.options.envFile\""
)]
fn invalid_env_file_type() {
figment::Jail::expect_with(|jail| {
jail.create_file(
super::CONFIG_FILENAME,
r#"
command: foo
options:
envFile: 123
"#,
)?;

super::load_jailed_config()?;

Ok(())
});
}

// Enums validation is currently not supported:
// https://github.com/Keats/validator/issues/77
// #[test]
// #[should_panic(expected = "todo")]
// fn invalid_env_file_path() {
// figment::Jail::expect_with(|jail| {
// jail.create_file(
// super::CONFIG_FILENAME,
// r#"
// command: foo
// options:
// envFile: '../.env'
// "#,
// )?;

// super::load_jailed_config()?;

// Ok(())
// });
// }
}
}
15 changes: 8 additions & 7 deletions crates/project/src/project.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use moon_config::{
};
use moon_constants::CONFIG_PROJECT_FILENAME;
use moon_logger::{color, debug, trace, Logable};
use moon_task::{FileGroup, Target, Task, TokenResolver, TokenSharedData};
use moon_task::{FileGroup, ResolverData, Target, Task, TokenResolver};
use moon_utils::path;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashMap, HashSet};
Expand Down Expand Up @@ -109,7 +109,7 @@ fn create_tasks_from_config(
project_config: &ProjectConfig,
global_config: &GlobalProjectConfig,
dependencies: &[DependencyConfig],
token_data: &TokenSharedData,
resolver_data: &ResolverData,
implicit_inputs: &[String],
) -> Result<TasksMap, ProjectError> {
let mut tasks = BTreeMap::<String, Task>::new();
Expand Down Expand Up @@ -222,12 +222,13 @@ fn create_tasks_from_config(
task.inputs.extend(implicit_inputs.iter().cloned());

// Resolve in order!
task.expand_env(resolver_data)?;
task.expand_deps(project_id, dependencies)?;
task.expand_inputs(TokenResolver::for_inputs(token_data))?;
task.expand_outputs(TokenResolver::for_outputs(token_data))?;
task.expand_inputs(TokenResolver::for_inputs(resolver_data))?;
task.expand_outputs(TokenResolver::for_outputs(resolver_data))?;

// Must be last as it references inputs/outputs
task.expand_args(TokenResolver::for_args(token_data))?;
task.expand_args(TokenResolver::for_args(resolver_data))?;
}

Ok(tasks)
Expand Down Expand Up @@ -309,14 +310,14 @@ impl Project {
let config = load_project_config(&log_target, &root, source)?;
let file_groups = create_file_groups_from_config(&log_target, &config, global_config);
let dependencies = create_dependencies_from_config(&log_target, &config);
let token_data = TokenSharedData::new(&file_groups, workspace_root, &root, &config);
let resolver_data = ResolverData::new(&file_groups, workspace_root, &root, &config);
let tasks = create_tasks_from_config(
&log_target,
id,
&config,
global_config,
&dependencies,
&token_data,
&resolver_data,
implicit_inputs,
)?;

Expand Down
5 changes: 5 additions & 0 deletions crates/project/tests/project_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,7 @@ mod tasks {
fn mock_merged_task_options_config(strategy: TaskMergeStrategy) -> TaskOptionsConfig {
TaskOptionsConfig {
cache: None,
env_file: None,
merge_args: Some(strategy.clone()),
merge_deps: Some(strategy.clone()),
merge_env: Some(strategy.clone()),
Expand All @@ -237,6 +238,7 @@ mod tasks {
fn mock_local_task_options_config(strategy: TaskMergeStrategy) -> TaskOptionsConfig {
TaskOptionsConfig {
cache: None,
env_file: None,
merge_args: Some(strategy.clone()),
merge_deps: Some(strategy.clone()),
merge_env: Some(strategy.clone()),
Expand All @@ -253,6 +255,7 @@ mod tasks {
fn stub_global_task_options_config() -> TaskOptionsConfig {
TaskOptionsConfig {
cache: Some(true),
env_file: None,
merge_args: None,
merge_deps: None,
merge_env: None,
Expand Down Expand Up @@ -733,6 +736,7 @@ mod tasks {
outputs: Some(string_vec!["a.ts", "b.ts"]),
options: TaskOptionsConfig {
cache: Some(true),
env_file: None,
merge_args: Some(TaskMergeStrategy::Append),
merge_deps: Some(TaskMergeStrategy::Prepend),
merge_env: Some(TaskMergeStrategy::Replace),
Expand Down Expand Up @@ -768,6 +772,7 @@ mod tasks {
outputs: Some(string_vec!["b.ts"]),
options: TaskOptionsConfig {
cache: None,
env_file: None,
merge_args: Some(TaskMergeStrategy::Append),
merge_deps: Some(TaskMergeStrategy::Prepend),
merge_env: Some(TaskMergeStrategy::Replace),
Expand Down
1 change: 1 addition & 0 deletions crates/task/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,6 @@ moon_error = { path = "../error" }
moon_logger = { path = "../logger" }
moon_utils = { path = "../utils" }
common-path = "1.0.0"
dotenvy = "0.15.1"
serde = { version = "1.0.140", features = ["derive"] }
thiserror = "1.0.31"
4 changes: 4 additions & 0 deletions crates/task/src/errors.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
use moon_error::MoonError;
use moon_utils::glob::GlobError;
use moon_utils::process::ArgsParseError;
use std::path::PathBuf;
use thiserror::Error;

#[derive(Error, Debug)]
pub enum TaskError {
#[error("Failed to parse env file <path>{0}</path>: {1}")]
InvalidEnvFile(PathBuf, String),

#[error(
"Task outputs do not support file globs. Found <file>{0}</file> in <target>{1}</target>."
)]
Expand Down
2 changes: 1 addition & 1 deletion crates/task/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,5 @@ pub use errors::*;
pub use file_group::FileGroup;
pub use target::{Target, TargetProjectScope};
pub use task::{Task, TaskOptions};
pub use token::{ResolverType, TokenResolver, TokenSharedData, TokenType};
pub use token::{ResolverData, ResolverType, TokenResolver, TokenType};
pub use types::*;
40 changes: 38 additions & 2 deletions crates/task/src/task.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
use crate::errors::{TargetError, TaskError};
use crate::target::{Target, TargetProjectScope};
use crate::token::TokenResolver;
use crate::token::{ResolverData, TokenResolver};
use crate::types::{EnvVars, TouchedFilePaths};
use moon_config::{
DependencyConfig, FileGlob, FilePath, InputValue, PlatformType, TargetID, TaskConfig,
TaskMergeStrategy, TaskOptionsConfig, TaskOutputStyle,
TaskMergeStrategy, TaskOptionEnvFile, TaskOptionsConfig, TaskOutputStyle,
};
use moon_logger::{color, debug, trace, Logable};
use moon_utils::{glob, path, regex::ENV_VAR, string_vec};
Expand All @@ -18,6 +18,8 @@ use std::path::PathBuf;
pub struct TaskOptions {
pub cache: bool,

pub env_file: Option<String>,

pub merge_args: TaskMergeStrategy,

pub merge_deps: TaskMergeStrategy,
Expand All @@ -43,6 +45,7 @@ impl Default for TaskOptions {
fn default() -> Self {
TaskOptions {
cache: true,
env_file: None,
merge_args: TaskMergeStrategy::Append,
merge_deps: TaskMergeStrategy::Append,
merge_env: TaskMergeStrategy::Append,
Expand All @@ -59,6 +62,10 @@ impl Default for TaskOptions {

impl TaskOptions {
pub fn merge(&mut self, config: &TaskOptionsConfig) {
if let Some(env_file) = &config.env_file {
self.env_file = env_file.to_option();
}

if let Some(merge_args) = &config.merge_args {
self.merge_args = merge_args.clone();
}
Expand Down Expand Up @@ -106,6 +113,14 @@ impl TaskOptions {

// Skip merge options until we need them

if let Some(env_file) = &self.env_file {
config.env_file = Some(if env_file == ".env" {
TaskOptionEnvFile::Enabled(true)
} else {
TaskOptionEnvFile::File(env_file.clone())
});
}

if let Some(output_style) = &self.output_style {
config.output_style = Some(output_style.clone());
}
Expand Down Expand Up @@ -201,6 +216,9 @@ impl Task {
log_target,
options: TaskOptions {
cache: cloned_options.cache.unwrap_or(!is_long_running),
env_file: cloned_options
.env_file
.map(|env_file| env_file.to_option().unwrap()),
merge_args: cloned_options.merge_args.unwrap_or_default(),
merge_deps: cloned_options.merge_deps.unwrap_or_default(),
merge_env: cloned_options.merge_env.unwrap_or_default(),
Expand Down Expand Up @@ -392,6 +410,24 @@ impl Task {
Ok(())
}

/// Expand environment variables by loading a `.env` file if configured.
pub fn expand_env(&mut self, data: &ResolverData) -> Result<(), TaskError> {
if let Some(env_file) = &self.options.env_file {
let env_path = data.project_root.join(env_file);
let error_handler =
|e: dotenvy::Error| TaskError::InvalidEnvFile(env_path.clone(), e.to_string());

for entry in dotenvy::from_path_iter(&env_path).map_err(error_handler)? {
let (key, value) = entry.map_err(error_handler)?;

// Vars defined in `env` take precedence over those in the env file
self.env.entry(key).or_insert(value);
}
}

Ok(())
}

/// Expand the inputs list to a set of absolute file paths, while resolving tokens.
pub fn expand_inputs(&mut self, token_resolver: TokenResolver) -> Result<(), TaskError> {
if self.inputs.is_empty() {
Expand Down
6 changes: 3 additions & 3 deletions crates/task/src/test.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use crate::{FileGroup, Target, Task, TaskError, TokenResolver, TokenSharedData};
use crate::{FileGroup, ResolverData, Target, Task, TaskError, TokenResolver};
use moon_config::{ProjectConfig, TaskConfig};
use moon_utils::string_vec;
use std::collections::HashMap;
Expand Down Expand Up @@ -84,9 +84,9 @@ pub fn create_expanded_task(
let mut task = create_initial_task(config);
let file_groups = create_file_groups();
let project_config = ProjectConfig::new(project_root);
let metadata =
TokenSharedData::new(&file_groups, workspace_root, project_root, &project_config);
let metadata = ResolverData::new(&file_groups, workspace_root, project_root, &project_config);

task.expand_env(&metadata)?;
task.expand_inputs(TokenResolver::for_inputs(&metadata))?;
task.expand_outputs(TokenResolver::for_outputs(&metadata))?;
task.expand_args(TokenResolver::for_args(&metadata))?; // Must be last
Expand Down
Loading

0 comments on commit 793cfb0

Please sign in to comment.