diff --git a/Cargo.lock b/Cargo.lock index 63b5c69..f592be0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -210,7 +210,7 @@ checksum = "7c5f0096a91d210159eceb2ff5e1c4da18388a170e1e3ce948aac9c8fdbbf595" dependencies = [ "heck", "proc-macro2", - "quote 1.0.8", + "quote 1.0.9", "syn 1.0.60", ] @@ -235,6 +235,7 @@ dependencies = [ "log", "serde", "serde_yaml", + "tempdir", "thiserror", ] @@ -300,6 +301,12 @@ dependencies = [ "widestring", ] +[[package]] +name = "fuchsia-cprng" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06f77d526c1a601b7c4cdd98f54b5eaabffc14d5f2f0296febdc7f357c6d3ba" + [[package]] name = "getrandom" version = "0.1.16" @@ -522,13 +529,50 @@ checksum = "7a6e920b65c65f10b2ae65c831a81a073a89edd28c7cce89475bff467ab4167a" [[package]] name = "quote" -version = "1.0.8" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "991431c3519a3f36861882da93630ce66b52918dcf1b8e2fd66b397fc96f28df" +checksum = "c3d0b9745dc2debf507c8422de05d7226cc1f0644216dfdfead988f9b1ab32a7" dependencies = [ "proc-macro2", ] +[[package]] +name = "rand" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "552840b97013b1a26992c11eac34bdd778e464601a4c2054b5f0bff7c6761293" +dependencies = [ + "fuchsia-cprng", + "libc", + "rand_core 0.3.1", + "rdrand", + "winapi", +] + +[[package]] +name = "rand_core" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a6fdeb83b075e8266dcc8762c22776f6877a63111121f5f8c7411e5be7eed4b" +dependencies = [ + "rand_core 0.4.2", +] + +[[package]] +name = "rand_core" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c33a3c44ca05fa6f1807d8e6743f3824e8509beca625669633be0acbdf509dc" + +[[package]] +name = "rdrand" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "678054eb77286b51581ba43620cc911abf02758c91f93f479767aed0f90458b2" +dependencies = [ + "rand_core 0.3.1", +] + [[package]] name = "redox_syscall" version = "0.1.57" @@ -564,6 +608,15 @@ version = "0.6.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5eb417147ba9860a96cfe72a0b93bf88fee1744b5636ec99ab20c1aa9376581" +[[package]] +name = "remove_dir_all" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acd125665422973a33ac9d3dd2df85edad0f4ae9b00dafb1a05e43a9f5ef8e7" +dependencies = [ + "winapi", +] + [[package]] name = "rust-argon2" version = "0.8.3" @@ -604,7 +657,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9391c295d64fc0abb2c556bad848f33cb8296276b1ad2677d1ae1ace4f258f31" dependencies = [ "proc-macro2", - "quote 1.0.8", + "quote 1.0.9", "syn 1.0.60", ] @@ -676,7 +729,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c700597eca8a5a762beb35753ef6b94df201c81cca676604f547495a0d7f0081" dependencies = [ "proc-macro2", - "quote 1.0.8", + "quote 1.0.9", "unicode-xid 0.2.1", ] @@ -689,6 +742,16 @@ dependencies = [ "unicode-xid 0.0.4", ] +[[package]] +name = "tempdir" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15f2b5fb00ccdf689e0149d1b1b3c03fead81c2b37735d812fa8bddbbf41b6d8" +dependencies = [ + "rand", + "remove_dir_all", +] + [[package]] name = "termcolor" version = "1.1.2" @@ -714,7 +777,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9be73a2caec27583d0046ef3796c3794f868a5bc813db689eed00c7631275cd1" dependencies = [ "proc-macro2", - "quote 1.0.8", + "quote 1.0.9", "syn 1.0.60", ] diff --git a/espanso-config/Cargo.toml b/espanso-config/Cargo.toml index 1179b83..c3337ae 100644 --- a/espanso-config/Cargo.toml +++ b/espanso-config/Cargo.toml @@ -10,4 +10,7 @@ anyhow = "1.0.38" thiserror = "1.0.23" serde = { version = "1.0.123", features = ["derive"] } serde_yaml = "0.8.17" -glob = "0.3.0" \ No newline at end of file +glob = "0.3.0" + +[dev-dependencies] +tempdir = "0.3.7" \ No newline at end of file diff --git a/espanso-config/src/config/macro_util.rs b/espanso-config/src/config/macro_util.rs new file mode 100644 index 0000000..6f8b975 --- /dev/null +++ b/espanso-config/src/config/macro_util.rs @@ -0,0 +1,20 @@ +#[macro_export] +macro_rules! merge { + ( $t:ident, $child:expr, $parent:expr, $( $x:ident ),* ) => { + { + $( + if $child.$x.is_none() { + $child.$x = $parent.$x.clone(); + } + )* + + // Build a temporary object to verify that all fields + // are being used at compile time + $t { + $( + $x: None, + )* + }; + } + }; +} diff --git a/espanso-config/src/config/mod.rs b/espanso-config/src/config/mod.rs index 2f4f1b0..9df82c8 100644 --- a/espanso-config/src/config/mod.rs +++ b/espanso-config/src/config/mod.rs @@ -4,6 +4,7 @@ use anyhow::Result; mod yaml; mod path; +mod macro_util; pub struct Config { pub label: Option, diff --git a/espanso-config/src/config/path.rs b/espanso-config/src/config/path.rs index ecdfdb1..b113960 100644 --- a/espanso-config/src/config/path.rs +++ b/espanso-config/src/config/path.rs @@ -3,7 +3,6 @@ use std::collections::HashSet; use glob::glob; use log::error; -// TODO: test pub fn calculate_paths<'a>(glob_patterns: impl Iterator) -> HashSet { let mut path_set = HashSet::new(); for glob_pattern in glob_patterns { @@ -15,9 +14,10 @@ pub fn calculate_paths<'a>(glob_patterns: impl Iterator) -> H Ok(path) => { path_set.insert(path.to_string_lossy().to_string()); } - Err(err) => { - error!("glob error when processing pattern: {}, with error: {}", glob_pattern, err) - } + Err(err) => error!( + "glob error when processing pattern: {}, with error: {}", + glob_pattern, err + ), } } } @@ -32,3 +32,41 @@ pub fn calculate_paths<'a>(glob_patterns: impl Iterator) -> H path_set } + +#[cfg(test)] +mod tests { + use std::fs::create_dir_all; + + use super::*; + use tempdir::TempDir; + + #[test] + fn calculate_paths_works_correctly() { + let dir = TempDir::new("tempconfig").unwrap(); + let match_dir = dir.path().join("match"); + create_dir_all(&match_dir).unwrap(); + + let sub_dir = match_dir.join("sub"); + create_dir_all(&sub_dir).unwrap(); + + std::fs::write(match_dir.join("base.yml"), "test").unwrap(); + std::fs::write(match_dir.join("another.yml"), "test").unwrap(); + std::fs::write(match_dir.join("_sub.yml"), "test").unwrap(); + std::fs::write(sub_dir.join("sub.yml"), "test").unwrap(); + + let result = calculate_paths(vec![ + format!("{}/**/*.yml", dir.path().to_string_lossy()), + format!("{}/match/sub/*.yml", dir.path().to_string_lossy()), + // Invalid path + "invalid".to_string(), + ].iter()); + + let mut expected = HashSet::new(); + expected.insert(format!("{}/match/base.yml", dir.path().to_string_lossy())); + expected.insert(format!("{}/match/another.yml", dir.path().to_string_lossy())); + expected.insert(format!("{}/match/_sub.yml", dir.path().to_string_lossy())); + expected.insert(format!("{}/match/sub/sub.yml", dir.path().to_string_lossy())); + + assert_eq!(result, expected); + } +} diff --git a/espanso-config/src/config/yaml.rs b/espanso-config/src/config/yaml.rs index 8fad014..9fd9a5a 100644 --- a/espanso-config/src/config/yaml.rs +++ b/espanso-config/src/config/yaml.rs @@ -1,67 +1,125 @@ -use std::collections::HashSet; +use anyhow::{private::kind::TraitKind, Result}; +use serde::{Deserialize, Serialize}; use std::iter::FromIterator; +use std::{collections::HashSet, convert::TryFrom}; + +use crate::{merge, util::is_yaml_empty}; use super::path::calculate_paths; const STANDARD_INCLUDES: &[&str] = &["match/**/*.yml"]; const STANDARD_EXCLUDES: &[&str] = &["match/**/_*.yml"]; -#[derive(Debug, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone)] pub struct YAMLConfig { + #[serde(default)] pub label: Option, + #[serde(default)] pub includes: Option>, + #[serde(default)] pub excludes: Option>, + #[serde(default)] pub extra_includes: Option>, + #[serde(default)] pub extra_excludes: Option>, - pub use_standard_includes: bool, + #[serde(default)] + pub use_standard_includes: Option, // Filters - + #[serde(default)] pub filter_title: Option, + + #[serde(default)] pub filter_class: Option, + + #[serde(default)] pub filter_exec: Option, + + #[serde(default)] pub filter_os: Option, } impl YAMLConfig { - // TODO test + pub fn parse_from_str(yaml: &str) -> Result { + // Because an empty string is not valid YAML but we want to support it anyway + if is_yaml_empty(yaml) { + return Ok(serde_yaml::from_str( + "arbitrary_field_that_will_not_block_the_parser: true", + )?); + } + + Ok(serde_yaml::from_str(yaml)?) + } + + pub fn merge_parent(&mut self, parent: &YAMLConfig) { + // Override the None fields with the parent's value + merge!( + YAMLConfig, + self, + parent, + + // Fields + label, + includes, + excludes, + extra_includes, + extra_excludes, + use_standard_includes, + filter_title, + filter_class, + filter_exec, + filter_os + ); + } + pub fn aggregate_includes(&self) -> HashSet { let mut includes = HashSet::new(); - if self.use_standard_includes { - STANDARD_INCLUDES.iter().for_each(|include| { includes.insert(include.to_string()); }) + if self.use_standard_includes.is_none() || self.use_standard_includes.unwrap() { + STANDARD_INCLUDES.iter().for_each(|include| { + includes.insert(include.to_string()); + }) } if let Some(yaml_includes) = self.includes.as_ref() { - yaml_includes.iter().for_each(|include| { includes.insert(include.to_string()); }) + yaml_includes.iter().for_each(|include| { + includes.insert(include.to_string()); + }) } if let Some(extra_includes) = self.extra_includes.as_ref() { - extra_includes.iter().for_each(|include| { includes.insert(include.to_string()); }) + extra_includes.iter().for_each(|include| { + includes.insert(include.to_string()); + }) } includes } - // TODO test pub fn aggregate_excludes(&self) -> HashSet { let mut excludes = HashSet::new(); - if self.use_standard_includes { - STANDARD_EXCLUDES.iter().for_each(|exclude| { excludes.insert(exclude.to_string()); }) + if self.use_standard_includes.is_none() || self.use_standard_includes.unwrap() { + STANDARD_EXCLUDES.iter().for_each(|exclude| { + excludes.insert(exclude.to_string()); + }) } if let Some(yaml_excludes) = self.excludes.as_ref() { - yaml_excludes.iter().for_each(|exclude| { excludes.insert(exclude.to_string()); }) + yaml_excludes.iter().for_each(|exclude| { + excludes.insert(exclude.to_string()); + }) } if let Some(extra_excludes) = self.extra_excludes.as_ref() { - extra_excludes.iter().for_each(|exclude| { excludes.insert(exclude.to_string()); }) + extra_excludes.iter().for_each(|exclude| { + excludes.insert(exclude.to_string()); + }) } excludes @@ -69,21 +127,181 @@ impl YAMLConfig { } // TODO: convert to TryFrom (check the matches section for an example) -impl From for super::Config { +impl TryFrom for super::Config { + type Error = anyhow::Error; + // TODO: test - fn from(yaml_config: YAMLConfig) -> Self { + fn try_from(yaml_config: YAMLConfig) -> Result { let includes = yaml_config.aggregate_includes(); let excludes = yaml_config.aggregate_excludes(); // Extract the paths let exclude_paths = calculate_paths(excludes.iter()); let include_paths = calculate_paths(includes.iter()); - - let match_files: Vec = Vec::from_iter(include_paths.difference(&exclude_paths).cloned()); - Self { + let match_files: Vec = + Vec::from_iter(include_paths.difference(&exclude_paths).cloned()); + + Ok(Self { label: yaml_config.label, match_files, - } + }) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + use std::iter::FromIterator; + use std::{convert::TryInto, fs::create_dir_all}; + use tempdir::TempDir; + + // fn create_config(yaml: &str) -> Result { + // let yaml_config: YAMLConfig = serde_yaml::from_str(yaml)?; + // let m: Config = yaml_config.try_into()?; + // Ok(m) + // } + + #[test] + fn aggregate_includes_empty_config() { + assert_eq!( + YAMLConfig::parse_from_str("").unwrap().aggregate_includes(), + HashSet::from_iter(vec!["match/**/*.yml".to_string(),].iter().cloned()) + ); + } + + #[test] + fn aggregate_includes_no_standard() { + assert_eq!( + YAMLConfig::parse_from_str("use_standard_includes: false").unwrap().aggregate_includes(), + HashSet::new() + ); + } + + #[test] + fn aggregate_includes_custom_includes() { + assert_eq!( + YAMLConfig::parse_from_str("includes: ['custom/*.yml']") + .unwrap() + .aggregate_includes(), + HashSet::from_iter( + vec!["match/**/*.yml".to_string(), "custom/*.yml".to_string()] + .iter() + .cloned() + ) + ); + } + + #[test] + fn aggregate_includes_extra_includes() { + assert_eq!( + YAMLConfig::parse_from_str("extra_includes: ['custom/*.yml']") + .unwrap() + .aggregate_includes(), + HashSet::from_iter( + vec!["match/**/*.yml".to_string(), "custom/*.yml".to_string()] + .iter() + .cloned() + ) + ); + } + + #[test] + fn aggregate_includes_includes_and_extra_includes() { + assert_eq!( + YAMLConfig::parse_from_str("includes: ['sub/*.yml']\nextra_includes: ['custom/*.yml']") + .unwrap() + .aggregate_includes(), + HashSet::from_iter( + vec!["match/**/*.yml".to_string(), "custom/*.yml".to_string(), "sub/*.yml".to_string()] + .iter() + .cloned() + ) + ); + } + + #[test] + fn aggregate_excludes_empty_config() { + assert_eq!( + YAMLConfig::parse_from_str("").unwrap().aggregate_excludes(), + HashSet::from_iter(vec!["match/**/_*.yml".to_string(),].iter().cloned()) + ); + } + + #[test] + fn aggregate_excludes_no_standard() { + assert_eq!( + YAMLConfig::parse_from_str("use_standard_includes: false").unwrap().aggregate_excludes(), + HashSet::new() + ); + } + + #[test] + fn aggregate_excludes_custom_excludes() { + assert_eq!( + YAMLConfig::parse_from_str("excludes: ['custom/*.yml']") + .unwrap() + .aggregate_excludes(), + HashSet::from_iter( + vec!["match/**/_*.yml".to_string(), "custom/*.yml".to_string()] + .iter() + .cloned() + ) + ); + } + + #[test] + fn aggregate_excludes_extra_excludes() { + assert_eq!( + YAMLConfig::parse_from_str("extra_excludes: ['custom/*.yml']") + .unwrap() + .aggregate_excludes(), + HashSet::from_iter( + vec!["match/**/_*.yml".to_string(), "custom/*.yml".to_string()] + .iter() + .cloned() + ) + ); + } + + #[test] + fn aggregate_excludes_excludes_and_extra_excludes() { + assert_eq!( + YAMLConfig::parse_from_str("excludes: ['sub/*.yml']\nextra_excludes: ['custom/*.yml']") + .unwrap() + .aggregate_excludes(), + HashSet::from_iter( + vec!["match/**/_*.yml".to_string(), "custom/*.yml".to_string(), "sub/*.yml".to_string()] + .iter() + .cloned() + ) + ); + } + + #[test] + fn merge_parent_field_parent_fallback() { + let parent = + YAMLConfig::parse_from_str("use_standard_includes: false").unwrap(); + let mut child = + YAMLConfig::parse_from_str("").unwrap(); + assert_eq!(child.use_standard_includes, None); + + child.merge_parent(&parent); + assert_eq!(child.use_standard_includes, Some(false)); + } + + #[test] + fn merge_parent_field_child_overwrite_parent() { + let parent = + YAMLConfig::parse_from_str("use_standard_includes: true").unwrap(); + let mut child = + YAMLConfig::parse_from_str("use_standard_includes: false").unwrap(); + assert_eq!(child.use_standard_includes, Some(false)); + + child.merge_parent(&parent); + assert_eq!(child.use_standard_includes, Some(false)); + } + + // TODO: test conversion to Config (we need to test that the file match resolution works) +} diff --git a/espanso-config/src/lib.rs b/espanso-config/src/lib.rs index f8037fc..9217979 100644 --- a/espanso-config/src/lib.rs +++ b/espanso-config/src/lib.rs @@ -17,6 +17,7 @@ * along with espanso. If not, see . */ +mod util; mod config; mod matches; diff --git a/espanso-config/src/util.rs b/espanso-config/src/util.rs new file mode 100644 index 0000000..d625b75 --- /dev/null +++ b/espanso-config/src/util.rs @@ -0,0 +1,38 @@ +/// Check if the given string represents an empty YAML. +/// In other words, it checks if the document is only composed +/// of spaces and/or comments +pub fn is_yaml_empty(yaml: &str) -> bool { + for line in yaml.lines() { + let trimmed_line = line.trim(); + if !trimmed_line.starts_with("#") && !trimmed_line.is_empty() { + return false + } + } + + true +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn is_yaml_empty_document_empty() { + assert_eq!(is_yaml_empty(""), true); + } + + #[test] + fn is_yaml_empty_document_with_comments() { + assert_eq!(is_yaml_empty("\n#comment \n \n"), true); + } + + #[test] + fn is_yaml_empty_document_with_comments_and_content() { + assert_eq!(is_yaml_empty("\n#comment \n field: true\n"), false); + } + + #[test] + fn is_yaml_empty_document_with_content() { + assert_eq!(is_yaml_empty("\nfield: true\n"), false); + } +} \ No newline at end of file