use crate::debug_config::ConfigFileDebugConfig;
use crate::defaults::default_quote;
use crate::parser::EscapeMode;
use crate::tasks::Task;
use crate::types::DynErrResult;
use crate::utils::{
get_path_relative_to_base, get_task_dependency_graph, read_env_file, to_os_task_name,
};
use indexmap::IndexMap;
use petgraph::algo::toposort;
use serde_derive::Deserialize;
use std::collections::HashMap;
use std::ffi::OsStr;
use std::fmt::{Display, Formatter};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::{env, error, fmt, fs};
pub type ConfigFileSharedPtr = Arc<Mutex<ConfigFile>>;
const CONFIG_FILES_PRIO: &[&str] = &["local.yamis", "yamis", "project.yamis"];
const GLOBAL_CONFIG_FILE: &str = "user.yamis";
#[cfg(not(test))]
const GLOBAL_CONFIG_FILE_PATH: &str = "~/.yamis";
const ALLOWED_EXTENSIONS: &[&str] = &["yml", "yaml", "toml"];
#[derive(Debug)]
pub(crate) enum ConfigError {
BadConfigFile(PathBuf, String),
DuplicateConfigFile(String),
}
impl Display for ConfigError {
fn fmt(&self, f: &mut Formatter) -> fmt::Result {
match *self {
ConfigError::BadConfigFile(ref path, ref reason) => write!(f, "Bad config file `{}`:\n {}", path.to_string_lossy(), reason),
ConfigError::DuplicateConfigFile(ref s) => write!(f,
"Config file `{}` defined multiple times with different extensions in the same directory.", s),
}
}
}
impl error::Error for ConfigError {}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ConfigFile {
#[allow(dead_code)] #[serde(default, skip_serializing)]
version: serde::de::IgnoredAny,
#[serde(skip)]
pub(crate) filepath: PathBuf,
#[serde(default)]
pub(crate) debug_config: ConfigFileDebugConfig,
#[serde(default)]
wd: Option<String>,
#[serde(default = "default_quote")]
pub(crate) quote: EscapeMode,
#[serde(default)]
pub(crate) tasks: HashMap<String, Task>,
pub(crate) env: Option<HashMap<String, String>>,
pub(crate) env_file: Option<String>,
#[serde(skip)]
pub(crate) loaded_tasks: HashMap<String, Arc<Task>>,
}
pub struct ConfigFilePaths {
index: usize,
root_reached: bool,
ended: bool,
single: bool,
current_dir: PathBuf,
cached: Vec<PathBuf>,
}
pub struct ConfigFilesContainer {
cached: IndexMap<PathBuf, ConfigFileSharedPtr>,
}
impl Iterator for ConfigFilePaths {
type Item = DynErrResult<PathBuf>;
fn next(&mut self) -> Option<Self::Item> {
if self.ended {
return None;
}
if self.single {
self.ended = true;
return if self.cached.is_empty() {
None
} else {
Some(Ok(PathBuf::from(self.cached.last().unwrap())))
};
}
let mut err: Option<Box<dyn error::Error>> = None;
while !self.root_reached {
let config_file_name = CONFIG_FILES_PRIO[self.index];
let checking_for_project_config = CONFIG_FILES_PRIO.len() - 1 == self.index;
self.index = (self.index + 1) % CONFIG_FILES_PRIO.len();
let found_file =
self.get_config_file_path(self.current_dir.as_path(), config_file_name);
let found_file = match found_file {
Ok(v) => v,
Err(e) => {
err = Some(e.into());
break;
}
};
if checking_for_project_config {
let new_current = self.current_dir.parent();
match new_current {
None => {
self.root_reached = true;
}
Some(new_current) => {
self.current_dir = new_current.to_path_buf();
}
}
}
if let Some(found_file) = found_file {
if checking_for_project_config {
self.root_reached = true;
}
self.cached.push(found_file.clone());
return Some(Ok(found_file));
}
}
self.root_reached = true;
self.ended = true;
if let Some(err) = err {
return Some(Err(err));
}
let global_config_dir = Self::get_global_config_file_dir();
let found_file = self.get_config_file_path(&global_config_dir, GLOBAL_CONFIG_FILE);
let found_file = match found_file {
Ok(v) => v,
Err(e) => {
return Some(Err(e.into()));
}
};
if let Some(found_file) = found_file {
self.cached.push(found_file.clone());
return Some(Ok(found_file));
}
None
}
}
impl ConfigFilePaths {
pub fn new<S: AsRef<OsStr> + ?Sized>(path: &S) -> ConfigFilePaths {
let current = PathBuf::from(path);
ConfigFilePaths {
index: 0,
ended: false,
root_reached: false,
single: false,
current_dir: current,
cached: Vec::with_capacity(2),
}
}
pub fn only<S: AsRef<OsStr> + ?Sized>(path: &S) -> DynErrResult<ConfigFilePaths> {
let path = PathBuf::from(path);
if !path.is_file() {
return Err(format!("{} does not exist", path.display()).into());
}
let config_files = ConfigFilePaths {
index: 0,
ended: false,
root_reached: true,
single: true,
current_dir: path.clone(),
cached: vec![path],
};
Ok(config_files)
}
#[cfg(not(test))]
pub(crate) fn get_global_config_file_dir() -> PathBuf {
let global_config_dir = shellexpand::tilde(GLOBAL_CONFIG_FILE_PATH);
PathBuf::from(global_config_dir.as_ref())
}
#[cfg(test)]
pub(crate) fn get_global_config_file_dir() -> PathBuf {
use assert_fs::TempDir;
use lazy_static::lazy_static;
lazy_static! {
static ref GLOBAL_CONFIG_DIR: TempDir = TempDir::new().unwrap();
pub static ref TEST_GLOBAL_CONFIG_PATH: PathBuf =
PathBuf::from(GLOBAL_CONFIG_DIR.path());
}
TEST_GLOBAL_CONFIG_PATH.clone()
}
fn get_config_file_path(
&self,
dir: &Path,
config_file_name: &str,
) -> Result<Option<PathBuf>, ConfigError> {
let mut files_count: u8 = 0;
let mut found_file: Option<PathBuf> = None;
for file_extension in ALLOWED_EXTENSIONS {
let file_name = format!("{}.{}", config_file_name, file_extension);
let path = dir.join(file_name);
if path.is_file() {
files_count += 1;
found_file = Some(path);
}
}
if files_count > 1 {
Err(ConfigError::DuplicateConfigFile(String::from(
config_file_name,
)))
} else {
Ok(found_file)
}
}
}
impl ConfigFilesContainer {
pub fn new() -> ConfigFilesContainer {
ConfigFilesContainer {
cached: IndexMap::new(),
}
}
pub fn read_config_file(&mut self, path: PathBuf) -> DynErrResult<ConfigFileSharedPtr> {
let config_file = ConfigFile::load(path.clone());
match config_file {
Ok(config_file) => {
let arc_config_file = Arc::new(Mutex::new(config_file));
let result = Ok(Arc::clone(&arc_config_file));
self.cached.insert(path, arc_config_file);
result
}
Err(e) => Err(e),
}
}
#[cfg(test)] pub fn has_task<S: AsRef<str>>(&mut self, name: S) -> bool {
for config_file in self.cached.values() {
let config_file_ptr = config_file.as_ref();
let handle = config_file_ptr.lock().unwrap();
if handle.has_task(name.as_ref()) {
return true;
}
}
false
}
}
impl Default for ConfigFilesContainer {
fn default() -> Self {
Self::new()
}
}
impl ConfigFile {
fn extract(path: &Path) -> DynErrResult<ConfigFile> {
let extension = path
.extension()
.unwrap_or_else(|| OsStr::new(""))
.to_string_lossy()
.to_string();
let is_yaml = match extension.as_str() {
"yaml" => true,
"yml" => true,
"toml" => false,
_ => {
return Err(ConfigError::BadConfigFile(
path.to_path_buf(),
String::from("Extension must be either `.toml`, `.yaml` or `.yml`"),
)
.into());
}
};
let contents = match fs::read_to_string(path) {
Ok(file_contents) => file_contents,
Err(e) => return Err(format!("There was an error reading the file:\n{}", e).into()),
};
if is_yaml {
Ok(serde_yaml::from_str(&contents)?)
} else {
Ok(toml::from_str(&contents)?)
}
}
pub fn load(path: PathBuf) -> DynErrResult<ConfigFile> {
let mut conf: ConfigFile = ConfigFile::extract(path.as_path())?;
conf.filepath = path;
if let Some(env_file_path) = &conf.env_file {
let env_file_path = get_path_relative_to_base(conf.directory(), &env_file_path);
let env_from_file = read_env_file(&env_file_path)?;
match conf.env.as_mut() {
None => {
conf.env = Some(HashMap::from_iter(env_from_file.into_iter()));
}
Some(env) => {
for (key, val) in env_from_file.into_iter() {
env.entry(key).or_insert(val);
}
}
}
}
let mut tasks = conf.get_flat_tasks()?;
let dep_graph = get_task_dependency_graph(&tasks)?;
let dependencies = toposort(&dep_graph, None);
let dependencies = match dependencies {
Ok(dependencies) => dependencies,
Err(e) => {
return Err(format!("Found a cyclic dependency for Task:\n{}", e.node_id()).into());
}
};
let dependencies: Vec<String> = dependencies
.iter()
.rev()
.map(|v| String::from(*v))
.collect();
for dependency_name in dependencies {
let mut task = tasks.remove(&dependency_name).unwrap();
let bases = std::mem::take(&mut task.bases);
for base in bases {
let os_task_name = format!("{}.{}", &base, env::consts::OS);
if let Some(base_task) = conf.loaded_tasks.get(&os_task_name) {
task.extend_task(base_task);
} else if let Some(base_task) = conf.loaded_tasks.get(&base) {
task.extend_task(base_task);
} else {
panic!("found non existent task {}", base);
}
}
conf.loaded_tasks.insert(dependency_name, Arc::new(task));
}
for (task_name, task) in tasks {
conf.loaded_tasks.insert(task_name, Arc::new(task));
}
Ok(conf)
}
pub fn directory(&self) -> &Path {
self.filepath.parent().unwrap()
}
pub fn working_directory(&self) -> Option<PathBuf> {
self.wd
.as_ref()
.map(|wd| get_path_relative_to_base(self.directory(), wd))
}
fn get_flat_tasks(&mut self) -> DynErrResult<HashMap<String, Task>> {
let mut flat_tasks = HashMap::new();
let tasks = std::mem::take(&mut self.tasks);
for (name, mut task) in tasks {
if task.linux.is_some() {
let os_task = std::mem::replace(&mut task.linux, None);
let mut os_task = *os_task.unwrap();
let os_task_name = format!("{}.linux", name);
if flat_tasks.contains_key(&os_task_name) {
return Err(format!("Duplicate task `{}`", os_task_name).into());
}
os_task.setup(&os_task_name, self.directory())?;
flat_tasks.insert(os_task_name, os_task);
}
if task.windows.is_some() {
let os_task = std::mem::replace(&mut task.windows, None);
let mut os_task = *os_task.unwrap();
let os_task_name = format!("{}.windows", name);
if flat_tasks.contains_key(&os_task_name) {
return Err(format!("Duplicate task `{}`", os_task_name).into());
}
os_task.setup(&os_task_name, self.directory())?;
flat_tasks.insert(os_task_name, os_task);
}
if task.macos.is_some() {
let os_task = std::mem::replace(&mut task.macos, None);
let mut os_task = *os_task.unwrap();
let os_task_name = format!("{}.macos", name);
if flat_tasks.contains_key(&os_task_name) {
return Err(format!("Duplicate task `{}`", os_task_name).into());
}
os_task.setup(&os_task_name, self.directory())?;
flat_tasks.insert(os_task_name, os_task);
}
task.setup(&name, self.directory())?;
flat_tasks.insert(name, task);
}
Ok(flat_tasks)
}
pub fn get_task(&self, task_name: &str) -> Option<Arc<Task>> {
let os_task_name = to_os_task_name(task_name);
if let Some(task) = self.loaded_tasks.get(&os_task_name) {
return Some(Arc::clone(task));
} else if let Some(task) = self.loaded_tasks.get(task_name) {
return Some(Arc::clone(task));
}
None
}
pub fn get_public_task(&self, task_name: &str) -> Option<Arc<Task>> {
let os_task_name = to_os_task_name(task_name);
if let Some(task) = self.loaded_tasks.get(&os_task_name) {
if task.is_private() {
return None;
}
return Some(Arc::clone(task));
} else if let Some(task) = self.loaded_tasks.get(task_name) {
if task.is_private() {
return None;
}
return Some(Arc::clone(task));
}
None
}
#[cfg(test)]
pub fn has_task(&self, task_name: &str) -> bool {
let os_task_name = to_os_task_name(task_name);
self.loaded_tasks.contains_key(&os_task_name) || self.loaded_tasks.contains_key(task_name)
}
pub fn get_task_names(&self) -> Vec<&String> {
self.loaded_tasks.keys().collect()
}
pub fn get_public_task_names(&self) -> Vec<&str> {
self.loaded_tasks
.values()
.filter(|t| !t.is_private())
.map(|t| t.get_name())
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use assert_fs::TempDir;
use std::fs::File;
use std::io::Write;
#[test]
fn test_discovery() {
let tmp_dir = TempDir::new().unwrap();
let project_config_path = tmp_dir.path().join("project.yamis.toml");
let mut project_config_file = File::create(project_config_path.as_path()).unwrap();
project_config_file
.write_all(
r#"
[tasks.hello_project]
script = "echo hello project"
"#
.as_bytes(),
)
.unwrap();
let config_path = tmp_dir.path().join("yamis.yaml");
let mut config_file = File::create(config_path.as_path()).unwrap();
config_file
.write_all(
r#"
tasks:
hello:
script: echo hello
"#
.as_bytes(),
)
.unwrap();
let local_config_path = tmp_dir.path().join("local.yamis.yaml");
let mut local_file = File::create(local_config_path.as_path()).unwrap();
local_file
.write_all(
r#"
tasks:
hello_local:
script: echo hello local
"#
.as_bytes(),
)
.unwrap();
let global_config_path =
ConfigFilePaths::get_global_config_file_dir().join("user.yamis.toml");
let mut global_config_file = File::create(global_config_path.as_path()).unwrap();
global_config_file
.write_all(
r#"
[tasks.hello_global]
script = "echo hello project"
"#
.as_bytes(),
)
.unwrap();
let mut config_files = ConfigFilesContainer::new();
let mut paths = ConfigFilePaths::new(&tmp_dir.path());
let local_path = paths.next().unwrap().unwrap();
let regular_path = paths.next().unwrap().unwrap();
let project_path = paths.next().unwrap().unwrap();
let global_path = paths.next().unwrap().unwrap();
assert!(paths.next().is_none());
config_files.read_config_file(local_path).unwrap();
config_files.read_config_file(regular_path).unwrap();
config_files.read_config_file(project_path).unwrap();
config_files.read_config_file(global_path).unwrap();
assert!(!config_files.has_task("non_existent"));
assert!(config_files.has_task("hello_project"));
assert!(config_files.has_task("hello"));
assert!(config_files.has_task("hello_local"));
assert!(config_files.has_task("hello_global"));
}
#[test]
fn test_discovery_given_file() {
let tmp_dir = TempDir::new().unwrap();
let sample_config_file_path = tmp_dir.path().join("sample.yamis.toml");
let mut sample_config_file = File::create(sample_config_file_path.as_path()).unwrap();
sample_config_file
.write_all(
r#"
[tasks.hello_project]
script = "echo hello project"
"#
.as_bytes(),
)
.unwrap();
let mut config_files = ConfigFilesContainer::new();
let mut paths = ConfigFilePaths::only(&sample_config_file_path).unwrap();
let sample_path = paths.next().unwrap().unwrap();
assert!(paths.next().is_none());
config_files.read_config_file(sample_path).unwrap();
assert!(config_files.has_task("hello_project"));
}
#[test]
fn test_dup_config_error() {
let tmp_dir = TempDir::new().unwrap();
let project_config_path = tmp_dir.path().join("project.yamis.toml");
File::create(project_config_path.as_path()).unwrap();
let config_path = tmp_dir.path().join("project.yamis.yaml");
File::create(config_path.as_path()).unwrap();
let mut paths = ConfigFilePaths::new(&tmp_dir.path());
let val = paths.next().unwrap();
assert!(val.is_err());
assert_eq!(
val.unwrap_err().to_string(),
"Config file `project.yamis` defined multiple times with different extensions in the same directory."
);
}
#[test]
fn test_config_file_only_iter() {
let path = PathBuf::from("sample_path.yml");
let mut config_files = ConfigFilePaths {
index: 0,
ended: false,
root_reached: true,
single: true,
current_dir: path.clone(),
cached: vec![],
};
assert!(config_files.next().is_none());
let mut config_files = ConfigFilePaths {
index: 0,
ended: false,
root_reached: true,
single: true,
current_dir: path.clone(),
cached: vec![path.clone()],
};
assert_eq!(config_files.next().unwrap().unwrap(), path);
}
#[test]
fn test_config_file_invalid_path() {
let cnfg = ConfigFile::extract(Path::new("non_existent"));
assert!(cnfg.is_err());
let cnfg = ConfigFile::extract(Path::new("non_existent.ext"));
assert!(cnfg.is_err());
let cnfg = ConfigFile::extract(Path::new("non_existent.yml"));
assert!(cnfg.is_err());
}
#[test]
fn test_container_read_config_error() {
let tmp_dir = TempDir::new().unwrap();
let project_config_path = tmp_dir.path().join("project.yamis.toml");
let mut project_config_file = File::create(project_config_path.as_path()).unwrap();
project_config_file
.write_all(
r#"
some invalid condig
"#
.as_bytes(),
)
.unwrap();
let mut config_files = ConfigFilesContainer::default();
let result = config_files.read_config_file(project_config_path);
assert!(result.is_err());
}
#[test]
fn test_config_file_read() {
let tmp_dir = TempDir::new().unwrap();
let dot_env_path = tmp_dir.path().join(".env");
let mut dot_env_file = File::create(dot_env_path.as_path()).unwrap();
dot_env_file
.write_all(
r#"VALUE_OVERRIDE=OLD_VALUE
OTHER_VALUE=HELLO
"#
.as_bytes(),
)
.unwrap();
let project_config_path = tmp_dir.path().join("project.yamis.yaml");
let mut project_config_file = File::create(project_config_path.as_path()).unwrap();
project_config_file
.write_all(
r#"
env_file: ".env"
env:
VALUE_OVERRIDE: NEW_VALUE
tasks:
hello_local:
script: echo hello local
"#
.as_bytes(),
)
.unwrap();
let config_file = ConfigFile::load(project_config_path).unwrap();
assert!(config_file.has_task("hello_local"));
let env = config_file.env.unwrap();
assert_eq!(env.get("VALUE_OVERRIDE").unwrap(), "NEW_VALUE");
assert_eq!(env.get("OTHER_VALUE").unwrap(), "HELLO");
}
#[test]
fn test_config_file_get_task_names() {
let tmp_dir = TempDir::new().unwrap();
let project_config_path = tmp_dir.path().join("project.yamis.yaml");
let mut project_config_file = File::create(project_config_path.as_path()).unwrap();
project_config_file
.write_all(
r#"
tasks:
task_1:
script: echo hello
task_2:
script: echo hello again
task_3:
script: echo hello again
private: true
"#
.as_bytes(),
)
.unwrap();
let config_file = ConfigFile::load(project_config_path).unwrap();
let mut task_names = config_file.get_task_names();
task_names.sort();
assert_eq!(task_names, vec!["task_1", "task_2", "task_3"]);
let mut task_names = config_file.get_public_task_names();
task_names.sort();
assert_eq!(task_names, vec!["task_1", "task_2"]);
}
#[test]
fn test_config_file_get_task() {
let tmp_dir = TempDir::new().unwrap();
let project_config_path = tmp_dir.path().join("project.yamis.yaml");
let mut project_config_file = File::create(project_config_path.as_path()).unwrap();
project_config_file
.write_all(
r#"
tasks:
task_1:
script: echo hello
task_2:
script: echo hello again
task_3:
script: echo hello again
private: true
"#
.as_bytes(),
)
.unwrap();
let config_file = ConfigFile::load(project_config_path).unwrap();
let task_nam = config_file.get_task("task_1");
assert!(task_nam.is_some());
assert_eq!(task_nam.unwrap().get_name(), "task_1");
let task_nam = config_file.get_task("task_2");
assert!(task_nam.is_some());
assert_eq!(task_nam.unwrap().get_name(), "task_2");
let task_nam = config_file.get_task("task_3");
assert!(task_nam.is_some());
assert_eq!(task_nam.unwrap().get_name(), "task_3");
}
#[test]
fn test_config_file_get_non_private_task() {
let tmp_dir = TempDir::new().unwrap();
let project_config_path = tmp_dir.path().join("project.yamis.yaml");
let mut project_config_file = File::create(project_config_path.as_path()).unwrap();
project_config_file
.write_all(
r#"
tasks:
task_1:
script: echo hello
task_2:
script: echo hello again
task_3:
script: echo hello again
private: true
"#
.as_bytes(),
)
.unwrap();
let config_file = ConfigFile::load(project_config_path).unwrap();
let task_nam = config_file.get_public_task("task_1");
assert!(task_nam.is_some());
assert_eq!(task_nam.unwrap().get_name(), "task_1");
let task_nam = config_file.get_public_task("task_2");
assert!(task_nam.is_some());
assert_eq!(task_nam.unwrap().get_name(), "task_2");
let task_nam = config_file.get_public_task("task_3");
assert!(task_nam.is_none());
}
#[test]
fn test_wrong_config_file_extension() {
let tmp_dir = TempDir::new().unwrap();
let project_config_path = tmp_dir.path().join("project.yamis.wrong");
File::create(project_config_path.as_path()).unwrap();
let config_file = ConfigFile::load(project_config_path);
assert!(config_file.is_err());
assert!(config_file
.unwrap_err()
.to_string()
.contains("Bad config file"));
}
}