diff --git a/Cargo.lock b/Cargo.lock index 68a8235..e6646bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -241,6 +241,7 @@ dependencies = [ "serde_yaml 0.8.9 (registry+https://github.com/rust-lang/crates.io-index)", "simplelog 0.7.1 (registry+https://github.com/rust-lang/crates.io-index)", "tempfile 3.1.0 (registry+https://github.com/rust-lang/crates.io-index)", + "walkdir 2.2.9 (registry+https://github.com/rust-lang/crates.io-index)", "widestring 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)", "zip 0.5.3 (registry+https://github.com/rust-lang/crates.io-index)", ] @@ -544,6 +545,14 @@ name = "ryu" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" +[[package]] +name = "same-file" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "winapi-util 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "serde" version = "1.0.99" @@ -697,6 +706,16 @@ name = "vec_map" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" +[[package]] +name = "walkdir" +version = "2.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "same-file 1.0.5 (registry+https://github.com/rust-lang/crates.io-index)", + "winapi 0.3.8 (registry+https://github.com/rust-lang/crates.io-index)", + "winapi-util 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "wasi" version = "0.7.0" @@ -721,6 +740,14 @@ name = "winapi-i686-pc-windows-gnu" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" +[[package]] +name = "winapi-util" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "winapi 0.3.8 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -814,6 +841,7 @@ dependencies = [ "checksum rust-argon2 0.5.1 (registry+https://github.com/rust-lang/crates.io-index)" = "4ca4eaef519b494d1f2848fc602d18816fed808a981aedf4f1f00ceb7c9d32cf" "checksum rustc-demangle 0.1.16 (registry+https://github.com/rust-lang/crates.io-index)" = "4c691c0e608126e00913e33f0ccf3727d5fc84573623b8d65b2df340b5201783" "checksum ryu 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)" = "c92464b447c0ee8c4fb3824ecc8383b81717b9f1e74ba2e72540aef7b9f82997" +"checksum same-file 1.0.5 (registry+https://github.com/rust-lang/crates.io-index)" = "585e8ddcedc187886a30fa705c47985c3fa88d06624095856b36ca0b82ff4421" "checksum serde 1.0.99 (registry+https://github.com/rust-lang/crates.io-index)" = "fec2851eb56d010dc9a21b89ca53ee75e6528bab60c11e89d38390904982da9f" "checksum serde_derive 1.0.99 (registry+https://github.com/rust-lang/crates.io-index)" = "cb4dc18c61206b08dc98216c98faa0232f4337e1e1b8574551d5bad29ea1b425" "checksum serde_json 1.0.40 (registry+https://github.com/rust-lang/crates.io-index)" = "051c49229f282f7c6f3813f8286cc1e3323e8051823fce42c7ea80fe13521704" @@ -832,10 +860,12 @@ dependencies = [ "checksum unicode-xid 0.1.0 (registry+https://github.com/rust-lang/crates.io-index)" = "fc72304796d0818e357ead4e000d19c9c174ab23dc11093ac919054d20a6a7fc" "checksum unicode-xid 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)" = "826e7639553986605ec5979c7dd957c7895e93eabed50ab2ffa7f6128a75097c" "checksum vec_map 0.8.1 (registry+https://github.com/rust-lang/crates.io-index)" = "05c78687fb1a80548ae3250346c3db86a80a7cdd77bda190189f2d0a0987c81a" +"checksum walkdir 2.2.9 (registry+https://github.com/rust-lang/crates.io-index)" = "9658c94fa8b940eab2250bd5a457f9c48b748420d71293b165c8cdbe2f55f71e" "checksum wasi 0.7.0 (registry+https://github.com/rust-lang/crates.io-index)" = "b89c3ce4ce14bdc6fb6beaf9ec7928ca331de5df7e5ea278375642a2f478570d" "checksum widestring 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)" = "effc0e4ff8085673ea7b9b2e3c73f6bd4d118810c9009ed8f1e16bd96c331db6" "checksum winapi 0.3.8 (registry+https://github.com/rust-lang/crates.io-index)" = "8093091eeb260906a183e6ae1abdba2ef5ef2257a21801128899c3fc699229c6" "checksum winapi-i686-pc-windows-gnu 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)" = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +"checksum winapi-util 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)" = "7168bab6e1daee33b4557efd0e95d5ca70a03706d39fa5f3fe7a236f584b03c9" "checksum winapi-x86_64-pc-windows-gnu 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)" = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" "checksum yaml-rust 0.4.3 (registry+https://github.com/rust-lang/crates.io-index)" = "65923dd1784f44da1d2c3dbbc5e822045628c590ba72123e1c73d3c230c4434d" "checksum zip 0.5.3 (registry+https://github.com/rust-lang/crates.io-index)" = "3c21bb410afa2bd823a047f5bda3adb62f51074ac7e06263b2c97ecdd47e9fc6" diff --git a/Cargo.toml b/Cargo.toml index d41c6b5..764199a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,7 @@ log-panics = {version = "2.0.0", features = ["with-backtrace"]} backtrace = "0.3.37" chrono = "0.4.9" lazy_static = "1.4.0" +walkdir = "2.2.9" [target.'cfg(unix)'.dependencies] libc = "0.2.62" diff --git a/src/config/mod.rs b/src/config/mod.rs index c0e154d..233828b 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -22,14 +22,15 @@ extern crate dirs; use std::path::{Path, PathBuf}; use std::{fs}; use crate::matcher::Match; -use std::fs::{File, create_dir_all}; +use std::fs::{File, create_dir_all, DirEntry}; use std::io::Read; use serde::{Serialize, Deserialize}; use crate::event::KeyModifier; -use std::collections::HashSet; +use std::collections::{HashSet, HashMap}; use log::{error}; use std::fmt; use std::error::Error; +use walkdir::WalkDir; pub(crate) mod runtime; @@ -42,6 +43,7 @@ const PACKAGES_FOLDER_NAME : &str = "packages"; // Default values for primitives fn default_name() -> String{ "default".to_owned() } +fn default_parent() -> String{ "self".to_owned() } fn default_filter_title() -> String{ "".to_owned() } fn default_filter_class() -> String{ "".to_owned() } fn default_filter_exec() -> String{ "".to_owned() } @@ -52,7 +54,7 @@ fn default_use_system_agent() -> bool { true } fn default_config_caching_interval() -> i32 { 800 } fn default_toggle_interval() -> u32 { 230 } fn default_backspace_limit() -> i32 { 3 } -fn default_exclude_parent_matches() -> bool {false} +fn default_exclude_default_matches() -> bool {false} fn default_matches() -> Vec { Vec::new() } #[derive(Clone, Debug, Serialize, Deserialize)] @@ -60,6 +62,9 @@ pub struct Configs { #[serde(default = "default_name")] pub name: String, + #[serde(default = "default_parent")] + pub parent: String, + #[serde(default = "default_filter_title")] pub filter_title: String, @@ -96,8 +101,8 @@ pub struct Configs { #[serde(default)] pub backend: BackendType, - #[serde(default = "default_exclude_parent_matches")] - pub exclude_parent_matches: bool, + #[serde(default = "default_exclude_default_matches")] + pub exclude_default_matches: bool, #[serde(default = "default_matches")] pub matches: Vec @@ -173,6 +178,32 @@ impl Configs { Err(ConfigLoadError::FileNotFound) } } + + fn merge_config(&mut self, new_config: Configs) { + let mut merged_matches = new_config.matches; + let mut trigger_set = HashSet::new(); + merged_matches.iter().for_each(|m| { + trigger_set.insert(m.trigger.clone()); + }); + let parent_matches : Vec = self.matches.iter().filter(|&m| { + !trigger_set.contains(&m.trigger) + }).map(|m| m.clone()).collect(); + + merged_matches.extend(parent_matches); + self.matches = merged_matches; + } + + fn merge_default(&mut self, default: &Configs) { + let mut trigger_set = HashSet::new(); + self.matches.iter().for_each(|m| { + trigger_set.insert(m.trigger.clone()); + }); + let default_matches : Vec = default.matches.iter().filter(|&m| { + !trigger_set.contains(&m.trigger) + }).map(|m| m.clone()).collect(); + + self.matches.extend(default_matches); + } } #[derive(Clone, Debug, Serialize, Deserialize)] @@ -191,78 +222,104 @@ impl ConfigSet { let default_file = dir_path.join(DEFAULT_CONFIG_FILE_NAME); let default = Configs::load_config(default_file.as_path())?; - // Load user defined configurations + // Analyze which config files has to be loaded - // TODO: loading with parent merging - - let mut specific = Vec::new(); + let mut target_files = Vec::new(); let specific_dir = dir_path.join(USER_CONFIGS_FOLDER_NAME); if specific_dir.exists() { - // Used to make sure no duplicates are present - let mut name_set = HashSet::new(); // TODO: think about integration with packages + let dir_entry = WalkDir::new(specific_dir); + target_files.extend(dir_entry); + } - let dir_entry = fs::read_dir(specific_dir); - if dir_entry.is_err() { - return Err(ConfigLoadError::UnableToReadFile) - } - let dir_entry = dir_entry.unwrap(); + let package_dir = dir_path.join(PACKAGES_FOLDER_NAME); + if package_dir.exists() { + let dir_entry = WalkDir::new(package_dir); + target_files.extend(dir_entry); + } - for entry in dir_entry { - let entry = entry; - if let Ok(entry) = entry { - let path = entry.path(); + // Load the user defined config files - // Skip non-yaml config files - if path.extension().unwrap_or_default().to_str().unwrap_or_default() != "yml" { - continue; - } + let mut name_set = HashSet::new(); + let mut children_map: HashMap> = HashMap::new(); + let mut root_configs = Vec::new(); + root_configs.push(default); - let mut config = Configs::load_config(path.as_path())?; + for entry in target_files { + if let Ok(entry) = entry { + let path = entry.path(); - if !config.validate_user_defined_config() { - return Err(ConfigLoadError::InvalidParameter(path.to_owned())) - } - - if config.name == "default" { - return Err(ConfigLoadError::MissingName(path.to_owned())); - } - - if name_set.contains(&config.name) { - return Err(ConfigLoadError::NameDuplicate(path.to_owned())); - } - - // Compute new match set, merging the parent's matches. - // Note: if an app-specific redefines a trigger already present in the - // default config, the latter gets overwritten. - if !config.exclude_parent_matches { - let mut merged_matches = config.matches.clone(); - let mut trigger_set = HashSet::new(); - merged_matches.iter().for_each(|m| { - trigger_set.insert(m.trigger.clone()); - }); - let parent_matches : Vec = default.matches.iter().filter(|&m| { - !trigger_set.contains(&m.trigger) - }).map(|m| m.clone()).collect(); - - merged_matches.extend(parent_matches); - config.matches = merged_matches; - } - - // TODO: check if it contains at least a filter, and warn the user about the problem - - name_set.insert(config.name.clone()); - specific.push(config); + // Skip non-yaml config files + if path.extension().unwrap_or_default().to_str().unwrap_or_default() != "yml" { + continue; } + + let mut config = Configs::load_config(&path)?; + + // Make sure the config does not contain reserved fields + if !config.validate_user_defined_config() { + return Err(ConfigLoadError::InvalidParameter(path.to_owned())) + } + + // No name specified, defaulting to the path name + if config.name == "default" { + config.name = path.to_str().unwrap_or_default().to_owned(); + } + + if name_set.contains(&config.name) { + return Err(ConfigLoadError::NameDuplicate(path.to_owned())); + } + + name_set.insert(config.name.clone()); + + if config.parent == "self" { // No parent, root config + root_configs.push(config); + }else{ // Children config + let children_vec = children_map.entry(config.parent.clone()).or_default(); + children_vec.push(config); + } + }else{ + eprintln!("Warning: Unable to read config file: {}", entry.unwrap_err()) + } + } + + // Merge the children config files + let mut configs = Vec::new(); + for root_config in root_configs { + let config = ConfigSet::reduce_configs(root_config, &children_map); + configs.push(config); + } + + // Separate default from specific + let default= configs.get(0).unwrap().clone(); + let mut specific = (&configs[1..]).to_vec().clone(); + + // Add default matches to specific configs when needed + for config in specific.iter_mut() { + if !config.exclude_default_matches { + config.merge_default(&default); } } Ok(ConfigSet { default, - specific: specific + specific }) } + fn reduce_configs(target: Configs, children_map: &HashMap>) -> Configs { + if children_map.contains_key(&target.name) { + let mut target = target; + for children in children_map.get(&target.name).unwrap() { + let children = Self::reduce_configs(children.clone(), children_map); + target.merge_config(children); + } + target + }else{ + target + } + } + pub fn load_default() -> Result { let res = dirs::home_dir(); if let Some(home_dir) = res { @@ -550,7 +607,7 @@ mod tests { } #[test] - fn test_config_set_specific_file_missing_name() { + fn test_config_set_specific_file_missing_name_auto_generated() { let tmp_dir = TempDir::new().expect("unable to create temp directory"); let default_path = tmp_dir.path().join(DEFAULT_CONFIG_FILE_NAME); fs::write(default_path, DEFAULT_CONFIG_FILE_CONTENT); @@ -561,14 +618,18 @@ mod tests { let user_defined_path_copy = user_defined_path.clone(); let config_set = ConfigSet::load(tmp_dir.path()); - assert!(config_set.is_err()); - assert_eq!(config_set.unwrap_err(), ConfigLoadError::MissingName(user_defined_path_copy)) + assert!(config_set.is_ok()); + assert_eq!(config_set.unwrap().specific[0].name, user_defined_path_copy.to_str().unwrap_or_default()) } pub fn create_temp_espanso_directory() -> TempDir { + create_temp_espanso_directory_with_default_content(DEFAULT_CONFIG_FILE_CONTENT) + } + + pub fn create_temp_espanso_directory_with_default_content(default_content: &str) -> TempDir { let tmp_dir = TempDir::new().expect("unable to create temp directory"); let default_path = tmp_dir.path().join(DEFAULT_CONFIG_FILE_NAME); - fs::write(default_path, DEFAULT_CONFIG_FILE_CONTENT); + fs::write(default_path, default_content); tmp_dir } @@ -590,6 +651,16 @@ mod tests { create_temp_file_in_dir(&user_config_dir, name, content) } + pub fn create_package_file(tmp_dir: &Path, package_name: &str, filename: &str, content: &str) -> PathBuf { + let package_config_dir = tmp_dir.join(PACKAGES_FOLDER_NAME); + let package_dir = package_config_dir.join(package_name); + if !package_dir.exists() { + create_dir_all(&package_dir); + } + + create_temp_file_in_dir(&package_dir, filename, content) + } + #[test] fn test_config_set_specific_file_duplicate_name() { let tmp_dir = create_temp_espanso_directory(); @@ -679,7 +750,7 @@ mod tests { let user_defined_path2 = create_user_config_file(tmp_dir.path(), "specific2.yml", r###" name: specific1 - exclude_parent_matches: true + exclude_default_matches: true matches: - trigger: "hello" @@ -708,7 +779,7 @@ mod tests { let user_defined_path2 = create_user_config_file(tmp_dir.path(), "specific.zzz", r###" name: specific1 - exclude_parent_matches: true + exclude_default_matches: true matches: - trigger: "hello" @@ -718,4 +789,196 @@ mod tests { let config_set = ConfigSet::load(tmp_dir.path()).unwrap(); assert_eq!(config_set.specific.len(), 0); } + + #[test] + fn test_config_set_no_parent_configs_works_correctly() { + let tmp_dir = create_temp_espanso_directory(); + + let user_defined_path = create_user_config_file(tmp_dir.path(), "specific.yml", r###" + name: specific1 + "###); + + let user_defined_path2 = create_user_config_file(tmp_dir.path(), "specific2.yml", r###" + name: specific2 + "###); + + let config_set = ConfigSet::load(tmp_dir.path()).unwrap(); + assert_eq!(config_set.specific.len(), 2); + } + + #[test] + fn test_config_set_default_parent_works_correctly() { + let tmp_dir = create_temp_espanso_directory_with_default_content(r###" + matches: + - trigger: hasta + replace: Hasta la vista + "###); + + let user_defined_path = create_user_config_file(tmp_dir.path(), "specific.yml", r###" + parent: default + + matches: + - trigger: "hello" + replace: "world" + "###); + + let config_set = ConfigSet::load(tmp_dir.path()).unwrap(); + assert_eq!(config_set.specific.len(), 0); + assert_eq!(config_set.default.matches.len(), 2); + assert!(config_set.default.matches.iter().any(|m| m.trigger == "hasta")); + assert!(config_set.default.matches.iter().any(|m| m.trigger == "hello")); + } + + #[test] + fn test_config_set_no_parent_should_not_merge() { + let tmp_dir = create_temp_espanso_directory_with_default_content(r###" + matches: + - trigger: hasta + replace: Hasta la vista + "###); + + let user_defined_path = create_user_config_file(tmp_dir.path(), "specific.yml", r###" + matches: + - trigger: "hello" + replace: "world" + "###); + + let config_set = ConfigSet::load(tmp_dir.path()).unwrap(); + assert_eq!(config_set.specific.len(), 1); + assert_eq!(config_set.default.matches.len(), 1); + assert!(config_set.default.matches.iter().any(|m| m.trigger == "hasta")); + assert!(!config_set.default.matches.iter().any(|m| m.trigger == "hello")); + assert!(config_set.specific[0].matches.iter().any(|m| m.trigger == "hello")); + } + + #[test] + fn test_config_set_default_nested_parent_works_correctly() { + let tmp_dir = create_temp_espanso_directory_with_default_content(r###" + matches: + - trigger: hasta + replace: Hasta la vista + "###); + + let user_defined_path = create_user_config_file(tmp_dir.path(), "specific.yml", r###" + name: custom1 + parent: default + + matches: + - trigger: "hello" + replace: "world" + "###); + + let user_defined_path2 = create_user_config_file(tmp_dir.path(), "specific2.yml", r###" + parent: custom1 + + matches: + - trigger: "super" + replace: "mario" + "###); + + let config_set = ConfigSet::load(tmp_dir.path()).unwrap(); + assert_eq!(config_set.specific.len(), 0); + assert_eq!(config_set.default.matches.len(), 3); + assert!(config_set.default.matches.iter().any(|m| m.trigger == "hasta")); + assert!(config_set.default.matches.iter().any(|m| m.trigger == "hello")); + assert!(config_set.default.matches.iter().any(|m| m.trigger == "super")); + } + + #[test] + fn test_config_set_parent_merge_children_priority_should_be_higher() { + let tmp_dir = create_temp_espanso_directory_with_default_content(r###" + matches: + - trigger: hasta + replace: Hasta la vista + "###); + + let user_defined_path = create_user_config_file(tmp_dir.path(), "specific.yml", r###" + parent: default + + matches: + - trigger: "hasta" + replace: "world" + "###); + + let config_set = ConfigSet::load(tmp_dir.path()).unwrap(); + assert_eq!(config_set.specific.len(), 0); + assert_eq!(config_set.default.matches.len(), 1); + assert!(config_set.default.matches.iter().any(|m| m.trigger == "hasta" && m.replace == "world")); + } + + #[test] + fn test_config_set_package_configs_default_merge() { + let tmp_dir = create_temp_espanso_directory_with_default_content(r###" + matches: + - trigger: hasta + replace: Hasta la vista + "###); + + let package_path = create_package_file(tmp_dir.path(), "package1", "package.yml", r###" + parent: default + + matches: + - trigger: "harry" + replace: "potter" + "###); + + let config_set = ConfigSet::load(tmp_dir.path()).unwrap(); + assert_eq!(config_set.specific.len(), 0); + assert_eq!(config_set.default.matches.len(), 2); + assert!(config_set.default.matches.iter().any(|m| m.trigger == "hasta")); + assert!(config_set.default.matches.iter().any(|m| m.trigger == "harry")); + } + + #[test] + fn test_config_set_package_configs_without_merge() { + let tmp_dir = create_temp_espanso_directory_with_default_content(r###" + matches: + - trigger: hasta + replace: Hasta la vista + "###); + + let package_path = create_package_file(tmp_dir.path(), "package1", "package.yml", r###" + matches: + - trigger: "harry" + replace: "potter" + "###); + + let config_set = ConfigSet::load(tmp_dir.path()).unwrap(); + assert_eq!(config_set.specific.len(), 1); + assert_eq!(config_set.default.matches.len(), 1); + assert!(config_set.default.matches.iter().any(|m| m.trigger == "hasta")); + assert!(config_set.specific[0].matches.iter().any(|m| m.trigger == "harry")); + } + + #[test] + fn test_config_set_package_configs_multiple_files() { + let tmp_dir = create_temp_espanso_directory_with_default_content(r###" + matches: + - trigger: hasta + replace: Hasta la vista + "###); + + let package_path = create_package_file(tmp_dir.path(), "package1", "package.yml", r###" + name: package1 + + matches: + - trigger: "harry" + replace: "potter" + "###); + + let package_path2 = create_package_file(tmp_dir.path(), "package1", "addon.yml", r###" + parent: package1 + + matches: + - trigger: "ron" + replace: "weasley" + "###); + + let config_set = ConfigSet::load(tmp_dir.path()).unwrap(); + assert_eq!(config_set.specific.len(), 1); + assert_eq!(config_set.default.matches.len(), 1); + assert!(config_set.default.matches.iter().any(|m| m.trigger == "hasta")); + assert!(config_set.specific[0].matches.iter().any(|m| m.trigger == "harry")); + assert!(config_set.specific[0].matches.iter().any(|m| m.trigger == "ron")); + } } \ No newline at end of file