diff --git a/Cargo.lock b/Cargo.lock index 13074cc..14617b0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -45,6 +45,12 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4efd02e230a02e18f92fc2735f44597385ed02ad8f831e7c1c1156ee5e1ab3a5" +[[package]] +name = "bitflags" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693" + [[package]] name = "blake2b_simd" version = "0.5.11" @@ -261,7 +267,9 @@ dependencies = [ "serde", "serde_yaml", "tempdir", + "tempfile", "thiserror", + "walkdir", ] [[package]] @@ -681,6 +689,15 @@ version = "0.1.57" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41cc0f7e4d5d4544e8861606a285bb08d3e70712ccc7d2b84d7c0ccfaf4b05ce" +[[package]] +name = "redox_syscall" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94341e4e44e24f6b591b59e47a8a027df12e008d73fd5672dbea9cc22f4507d9" +dependencies = [ + "bitflags 1.2.1", +] + [[package]] name = "redox_users" version = "0.3.5" @@ -688,7 +705,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "de0737333e7a9502c789a36d7c7fa6092a49895d4faa31ca5df163857ded2e9d" dependencies = [ "getrandom 0.1.16", - "redox_syscall", + "redox_syscall 0.1.57", "rust-argon2", ] @@ -736,6 +753,15 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71d301d4193d031abdd79ff7e3dd721168a9572ef3fe51a1517aba235bd8f86e" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "scopeguard" version = "1.1.0" @@ -853,6 +879,20 @@ dependencies = [ "remove_dir_all", ] +[[package]] +name = "tempfile" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dac1c663cfc93810f88aed9b8941d48cabf856a1b111c29a40439018d870eb22" +dependencies = [ + "cfg-if", + "libc", + "rand 0.8.3", + "redox_syscall 0.2.5", + "remove_dir_all", + "winapi", +] + [[package]] name = "termcolor" version = "1.1.2" @@ -935,6 +975,17 @@ dependencies = [ "libc", ] +[[package]] +name = "walkdir" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "777182bc735b6424e1a57516d35ed72cb8019d85c8c9bf536dccb3445c1a2f7d" +dependencies = [ + "same-file", + "winapi", + "winapi-util", +] + [[package]] name = "wasi" version = "0.9.0+wasi-snapshot-preview1" @@ -1012,7 +1063,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1945e12e16b951721d7976520b0832496ef79c31602c7a29d950de79ba74621" dependencies = [ - "bitflags", + "bitflags 0.9.1", ] [[package]] diff --git a/espanso-config/Cargo.toml b/espanso-config/Cargo.toml index c086296..e0d5e5f 100644 --- a/espanso-config/Cargo.toml +++ b/espanso-config/Cargo.toml @@ -14,6 +14,8 @@ glob = "0.3.0" regex = "1.4.3" lazy_static = "1.4.0" dunce = "1.0.1" +walkdir = "2.3.1" [dev-dependencies] -tempdir = "0.3.7" \ No newline at end of file +tempdir = "0.3.7" +tempfile = "3.2.0" \ No newline at end of file diff --git a/espanso-config/src/legacy/config.rs b/espanso-config/src/legacy/config.rs new file mode 100644 index 0000000..cc8a6f3 --- /dev/null +++ b/espanso-config/src/legacy/config.rs @@ -0,0 +1,1890 @@ +// This file is taken from the old version of espanso, and used to load +// the "legacy" config format + +/* + * This file is part of espanso. + * + * Copyright (C) 2019 Federico Terzi + * + * espanso is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * espanso is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with espanso. If not, see . + */ + +use super::model::{KeyModifier, PasteShortcut}; +use log::error; +use serde::{Deserialize, Serialize}; +use serde_yaml::Value; +use std::collections::{HashMap, HashSet}; +use std::error::Error; +use std::fmt; +use std::fs::File; +use std::io::Read; +use std::path::{Path, PathBuf}; +use walkdir::{DirEntry, WalkDir}; + +pub const DEFAULT_CONFIG_FILE_NAME: &str = "default.yml"; +pub const USER_CONFIGS_FOLDER_NAME: &str = "user"; + +// 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() +} +fn default_log_level() -> i32 { + 0 +} +fn default_conflict_check() -> bool { + false +} +fn default_ipc_server_port() -> i32 { + 34982 +} +fn default_worker_ipc_server_port() -> i32 { + 34983 +} +fn default_use_system_agent() -> bool { + true +} +fn default_config_caching_interval() -> i32 { + 800 +} +fn default_word_separators() -> Vec { + vec![' ', ',', '.', '?', '!', '\r', '\n', 22u8 as char] +} +fn default_toggle_interval() -> u32 { + 230 +} +fn default_toggle_key() -> KeyModifier { + KeyModifier::ALT +} +fn default_preserve_clipboard() -> bool { + true +} +fn default_passive_match_regex() -> String { + "(?P:\\p{L}+)(/(?P.*)/)?".to_owned() +} +fn default_passive_arg_delimiter() -> char { + '/' +} +fn default_passive_arg_escape() -> char { + '\\' +} +fn default_passive_delay() -> u64 { + 100 +} +fn default_passive_key() -> KeyModifier { + KeyModifier::OFF +} +fn default_enable_passive() -> bool { + false +} +fn default_enable_active() -> bool { + true +} +fn default_backspace_limit() -> i32 { + 3 +} +fn default_backspace_delay() -> i32 { + 0 +} +fn default_inject_delay() -> i32 { + 0 +} +fn default_restore_clipboard_delay() -> i32 { + 300 +} +fn default_exclude_default_entries() -> bool { + false +} +fn default_secure_input_watcher_enabled() -> bool { + true +} +fn default_secure_input_notification() -> bool { + true +} +fn default_show_notifications() -> bool { + true +} +fn default_auto_restart() -> bool { + true +} +fn default_undo_backspace() -> bool { + true +} +fn default_show_icon() -> bool { + true +} +fn default_fast_inject() -> bool { + true +} +fn default_secure_input_watcher_interval() -> i32 { + 5000 +} +fn default_matches() -> Vec { + Vec::new() +} +fn default_global_vars() -> Vec { + Vec::new() +} +fn default_modulo_path() -> Option { + None +} +fn default_post_inject_delay() -> u64 { + 100 +} + +fn default_wait_for_modifiers_release() -> bool { + false +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +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, + + #[serde(default = "default_filter_class")] + pub filter_class: String, + + #[serde(default = "default_filter_exec")] + pub filter_exec: String, + + #[serde(default = "default_log_level")] + pub log_level: i32, + + #[serde(default = "default_conflict_check")] + pub conflict_check: bool, + + #[serde(default = "default_ipc_server_port")] + pub ipc_server_port: i32, + + #[serde(default = "default_worker_ipc_server_port")] + pub worker_ipc_server_port: i32, + + #[serde(default = "default_use_system_agent")] + pub use_system_agent: bool, + + #[serde(default = "default_config_caching_interval")] + pub config_caching_interval: i32, + + #[serde(default = "default_word_separators")] + pub word_separators: Vec, // TODO: add parsing test + + #[serde(default = "default_toggle_key")] + pub toggle_key: KeyModifier, + + #[serde(default = "default_toggle_interval")] + pub toggle_interval: u32, + + #[serde(default = "default_preserve_clipboard")] + pub preserve_clipboard: bool, + + #[serde(default = "default_passive_match_regex")] + pub passive_match_regex: String, + + #[serde(default = "default_passive_arg_delimiter")] + pub passive_arg_delimiter: char, + + #[serde(default = "default_passive_arg_escape")] + pub passive_arg_escape: char, + + #[serde(default = "default_passive_key")] + pub passive_key: KeyModifier, + + #[serde(default = "default_passive_delay")] + pub passive_delay: u64, + + #[serde(default = "default_enable_passive")] + pub enable_passive: bool, + + #[serde(default = "default_enable_active")] + pub enable_active: bool, + + #[serde(default = "default_undo_backspace")] + pub undo_backspace: bool, + + #[serde(default)] + pub paste_shortcut: PasteShortcut, + + #[serde(default = "default_backspace_limit")] + pub backspace_limit: i32, + + #[serde(default = "default_restore_clipboard_delay")] + pub restore_clipboard_delay: i32, + + #[serde(default = "default_secure_input_watcher_enabled")] + pub secure_input_watcher_enabled: bool, + + #[serde(default = "default_secure_input_watcher_interval")] + pub secure_input_watcher_interval: i32, + + #[serde(default = "default_post_inject_delay")] + pub post_inject_delay: u64, + + #[serde(default = "default_secure_input_notification")] + pub secure_input_notification: bool, + + #[serde(default)] + pub backend: BackendType, + + #[serde(default = "default_exclude_default_entries")] + pub exclude_default_entries: bool, + + #[serde(default = "default_show_notifications")] + pub show_notifications: bool, + + #[serde(default = "default_show_icon")] + pub show_icon: bool, + + #[serde(default = "default_fast_inject")] + pub fast_inject: bool, + + #[serde(default = "default_backspace_delay")] + pub backspace_delay: i32, + + #[serde(default = "default_inject_delay")] + pub inject_delay: i32, + + #[serde(default = "default_auto_restart")] + pub auto_restart: bool, + + #[serde(default = "default_matches")] + pub matches: Vec, + + #[serde(default = "default_global_vars")] + pub global_vars: Vec, + + #[serde(default = "default_modulo_path")] + pub modulo_path: Option, + + #[serde(default = "default_wait_for_modifiers_release")] + pub wait_for_modifiers_release: bool, +} + +// Macro used to validate config fields +#[macro_export] +macro_rules! validate_field { + ($result:expr, $field:expr, $def_value:expr) => { + if $field != $def_value { + let mut field_name = stringify!($field); + if field_name.starts_with("self.") { + field_name = &field_name[5..]; // Remove the 'self.' prefix + } + error!("Validation error, parameter '{}' is reserved and can be only used in the default.yml config file", field_name); + $result = false; + } + }; +} + +impl Configs { + /* + * Validate the Config instance. + * It makes sure that user defined config instances do not define + * attributes reserved to the default config. + */ + fn validate_user_defined_config(&self) -> bool { + let mut result = true; + + validate_field!( + result, + self.config_caching_interval, + default_config_caching_interval() + ); + validate_field!(result, self.log_level, default_log_level()); + validate_field!(result, self.conflict_check, default_conflict_check()); + validate_field!(result, self.toggle_key, default_toggle_key()); + validate_field!(result, self.toggle_interval, default_toggle_interval()); + validate_field!(result, self.backspace_limit, default_backspace_limit()); + validate_field!(result, self.ipc_server_port, default_ipc_server_port()); + validate_field!(result, self.use_system_agent, default_use_system_agent()); + validate_field!( + result, + self.preserve_clipboard, + default_preserve_clipboard() + ); + validate_field!( + result, + self.passive_match_regex, + default_passive_match_regex() + ); + validate_field!( + result, + self.passive_arg_delimiter, + default_passive_arg_delimiter() + ); + validate_field!( + result, + self.passive_arg_escape, + default_passive_arg_escape() + ); + validate_field!(result, self.passive_key, default_passive_key()); + validate_field!( + result, + self.restore_clipboard_delay, + default_restore_clipboard_delay() + ); + validate_field!( + result, + self.secure_input_watcher_enabled, + default_secure_input_watcher_enabled() + ); + validate_field!( + result, + self.secure_input_watcher_interval, + default_secure_input_watcher_interval() + ); + validate_field!( + result, + self.secure_input_notification, + default_secure_input_notification() + ); + validate_field!( + result, + self.show_notifications, + default_show_notifications() + ); + validate_field!(result, self.show_icon, default_show_icon()); + + result + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum BackendType { + Inject, + Clipboard, + + // On Linux systems there is a long standing issue with text injection (which + // in general is better than Clipboard copy/pasting) that prevents certain + // apps from correctly handling special characters (such as emojis or accented letters) + // when injected. For this reason, espanso initially defaulted on the Clipboard + // backend on Linux, as it was the most reliable (working in 99% of cases), + // even though it was less efficient and with a few inconveniences (for example, the + // previous clipboard content being overwritten). + // The Auto backend tries to take it a step further, by automatically determining + // when an injection is possible (only ascii characters in the replacement), and falling + // back to the Clipboard backend otherwise. + // Should only be used on Linux systems. + Auto, +} +impl Default for BackendType { + // The default backend varies based on the operating system. + // On Windows and macOS, the Inject backend is working great and should + // be preferred as it doesn't override the clipboard. + // On the other hand, on linux it has many problems due to the bugs + // of the libxdo used. For this reason, Clipboard will be the default + // backend on Linux from version v0.3.0 + + #[cfg(not(target_os = "linux"))] + fn default() -> Self { + BackendType::Inject + } + + #[cfg(target_os = "linux")] + fn default() -> Self { + BackendType::Auto + } +} + +impl Configs { + fn load_config(path: &Path) -> Result { + let file_res = File::open(path); + if let Ok(mut file) = file_res { + let mut contents = String::new(); + let res = file.read_to_string(&mut contents); + + if res.is_err() { + return Err(ConfigLoadError::UnableToReadFile); + } + + let config_res = serde_yaml::from_str(&contents); + + match config_res { + Ok(config) => Ok(config), + Err(e) => Err(ConfigLoadError::InvalidYAML(path.to_owned(), e.to_string())), + } + } else { + eprintln!("Error: Cannot load file {:?}", path); + Err(ConfigLoadError::FileNotFound) + } + } + + fn merge_overwrite(&mut self, new_config: Configs) { + // Merge matches + let mut merged_matches = new_config.matches; + let mut match_trigger_set = HashSet::new(); + merged_matches.iter().for_each(|m| { + match_trigger_set.extend(triggers_for_match(m)); + }); + let parent_matches: Vec = self + .matches + .iter() + .filter(|&m| { + !triggers_for_match(m) + .iter() + .any(|trigger| match_trigger_set.contains(trigger)) + }) + .cloned() + .collect(); + + merged_matches.extend(parent_matches); + self.matches = merged_matches; + + // Merge global variables + let mut merged_global_vars = new_config.global_vars; + let mut vars_name_set = HashSet::new(); + merged_global_vars.iter().for_each(|m| { + vars_name_set.insert(name_for_global_var(m)); + }); + let parent_vars: Vec = self + .global_vars + .iter() + .filter(|&m| !vars_name_set.contains(&name_for_global_var(m))) + .cloned() + .collect(); + + merged_global_vars.extend(parent_vars); + self.global_vars = merged_global_vars; + } + + fn merge_no_overwrite(&mut self, default: &Configs) { + // Merge matches + let mut match_trigger_set = HashSet::new(); + self.matches.iter().for_each(|m| { + match_trigger_set.extend(triggers_for_match(m)); + }); + let default_matches: Vec = default + .matches + .iter() + .filter(|&m| { + !triggers_for_match(m) + .iter() + .any(|trigger| match_trigger_set.contains(trigger)) + }) + .cloned() + .collect(); + + self.matches.extend(default_matches); + + // Merge global variables + let mut vars_name_set = HashSet::new(); + self.global_vars.iter().for_each(|m| { + vars_name_set.insert(name_for_global_var(m)); + }); + let default_vars: Vec = default + .global_vars + .iter() + .filter(|&m| !vars_name_set.contains(&name_for_global_var(m))) + .cloned() + .collect(); + + self.global_vars.extend(default_vars); + } +} + +fn triggers_for_match(m: &Value) -> Vec { + if let Some(triggers) = m.get("triggers").and_then(|v| v.as_sequence()) { + triggers.into_iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect() + } else if let Some(trigger) = m.get("trigger").and_then(|v| v.as_str()) { + vec![trigger.to_string()] + } else { + panic!("Match does not have any trigger defined: {:?}", m) + } +} + +fn replace_for_match(m: &Value) -> String { + m.get("replace").and_then(|v| v.as_str()).expect("match is missing replace field").to_string() +} + +fn name_for_global_var(v: &Value) -> String { + v.get("name").and_then(|v| v.as_str()).expect("global var is missing name field").to_string() +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ConfigSet { + pub default: Configs, + pub specific: Vec, +} + +impl ConfigSet { + pub fn load(config_dir: &Path, package_dir: &Path) -> Result { + if !config_dir.is_dir() { + return Err(ConfigLoadError::InvalidConfigDirectory); + } + + // Load default configuration + let default_file = config_dir.join(DEFAULT_CONFIG_FILE_NAME); + let default = Configs::load_config(default_file.as_path())?; + + // Check that a compatible backend is used, otherwise warn the user + if cfg!(not(target_os = "linux")) && default.backend == BackendType::Auto { + eprintln!( + "Warning: Using Auto backend is only supported on Linux, falling back to Inject backend." + ); + } + + // Analyze which config files have to be loaded + + let mut target_files = Vec::new(); + + let specific_dir = config_dir.join(USER_CONFIGS_FOLDER_NAME); + if specific_dir.exists() { + let dir_entry = WalkDir::new(specific_dir); + target_files.extend(dir_entry); + } + + let package_files = if package_dir.exists() { + let dir_entry = WalkDir::new(package_dir); + dir_entry.into_iter().collect() + } else { + vec![] + }; + + // Load the user defined config files + + let mut name_set = HashSet::new(); + let mut children_map: HashMap> = HashMap::new(); + let mut package_map: HashMap> = HashMap::new(); + let mut root_configs = Vec::new(); + root_configs.push(default); + + let mut file_loader = |entry: walkdir::Result, + dest_map: &mut HashMap>| + -> Result<(), ConfigLoadError> { + match entry { + Ok(entry) => { + let path = entry.path(); + + // Skip non-yaml config files + if path + .extension() + .unwrap_or_default() + .to_str() + .unwrap_or_default() + != "yml" + { + return Ok(()); + } + + // Skip hidden files + if path + .file_name() + .unwrap_or_default() + .to_str() + .unwrap_or_default() + .starts_with(".") + { + return Ok(()); + } + + 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 = dest_map.entry(config.parent.clone()).or_default(); + children_vec.push(config); + } + } + Err(e) => { + eprintln!("Warning: Unable to read config file: {}", e); + } + } + + Ok(()) + }; + + // Load the default and user specific configs + for entry in target_files { + file_loader(entry, &mut children_map)?; + } + + // Load the package related configs + for entry in package_files { + file_loader(entry, &mut package_map)?; + } + + // Merge the children config files + let mut configs_without_packages = Vec::new(); + for root_config in root_configs { + let config = ConfigSet::reduce_configs(root_config, &children_map, true); + configs_without_packages.push(config); + } + + // Merge package files + // Note: we need two different steps as the packages have a lower priority + // than configs. + let mut configs = Vec::new(); + for root_config in configs_without_packages { + let config = ConfigSet::reduce_configs(root_config, &package_map, false); + configs.push(config); + } + + // Separate default from specific + let default = configs.get(0).unwrap().clone(); + let mut specific = (&configs[1..]).to_vec().clone(); + + // Add default entries to specific configs when needed + for config in specific.iter_mut() { + if !config.exclude_default_entries { + config.merge_no_overwrite(&default); + } + } + + // Check if some triggers are conflicting with each other + // For more information, see: https://github.com/federico-terzi/espanso/issues/135 + if default.conflict_check { + let has_conflicts = Self::has_conflicts(&default, &specific); + if has_conflicts { + eprintln!("Warning: some triggers had conflicts and may not behave as intended"); + eprintln!("To turn off this check, add \"conflict_check: false\" in the configuration"); + } + } + + Ok(ConfigSet { default, specific }) + } + + fn reduce_configs( + target: Configs, + children_map: &HashMap>, + higher_priority: bool, + ) -> 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, higher_priority); + if higher_priority { + target.merge_overwrite(children); + } else { + target.merge_no_overwrite(&children); + } + } + target + } else { + target + } + } + + fn has_conflicts(default: &Configs, specific: &Vec) -> bool { + let mut sorted_triggers: Vec = default + .matches + .iter() + .flat_map(|t| triggers_for_match(t)) + .collect(); + sorted_triggers.sort(); + + let mut has_conflicts = Self::list_has_conflicts(&sorted_triggers); + + for s in specific.iter() { + let mut specific_triggers: Vec = + s.matches.iter().flat_map(|t| triggers_for_match(t)).collect(); + specific_triggers.sort(); + has_conflicts |= Self::list_has_conflicts(&specific_triggers); + } + + has_conflicts + } + + fn list_has_conflicts(sorted_list: &Vec) -> bool { + if sorted_list.len() <= 1 { + return false; + } + + let mut has_conflicts = false; + + for (i, item) in sorted_list.iter().skip(1).enumerate() { + let previous = &sorted_list[i]; + if item.starts_with(previous) { + has_conflicts = true; + eprintln!( + "Warning: trigger '{}' is conflicting with '{}' and may not behave as intended", + item, previous + ); + } + } + + has_conflicts + } +} + +// Error handling +#[derive(Debug, PartialEq)] +pub enum ConfigLoadError { + FileNotFound, + UnableToReadFile, + InvalidYAML(PathBuf, String), + InvalidConfigDirectory, + InvalidParameter(PathBuf), + NameDuplicate(PathBuf), + UnableToCreateDefaultConfig, +} + +impl fmt::Display for ConfigLoadError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + ConfigLoadError::FileNotFound => write!(f, "File not found"), + ConfigLoadError::UnableToReadFile => write!(f, "Unable to read config file"), + ConfigLoadError::InvalidYAML(path, e) => write!(f, "Error parsing YAML file '{}', invalid syntax: {}", path.to_str().unwrap_or_default(), e), + ConfigLoadError::InvalidConfigDirectory => write!(f, "Invalid config directory"), + ConfigLoadError::InvalidParameter(path) => write!(f, "Invalid parameter in '{}', use of reserved parameters in used defined configs is not permitted", path.to_str().unwrap_or_default()), + ConfigLoadError::NameDuplicate(path) => write!(f, "Found duplicate 'name' in '{}', please use different names", path.to_str().unwrap_or_default()), + ConfigLoadError::UnableToCreateDefaultConfig => write!(f, "Could not generate default config file"), + } + } +} + +impl Error for ConfigLoadError { + fn description(&self) -> &str { + match self { + ConfigLoadError::FileNotFound => "File not found", + ConfigLoadError::UnableToReadFile => "Unable to read config file", + ConfigLoadError::InvalidYAML(_, _) => "Error parsing YAML file, invalid syntax", + ConfigLoadError::InvalidConfigDirectory => "Invalid config directory", + ConfigLoadError::InvalidParameter(_) => { + "Invalid parameter, use of reserved parameters in user defined configs is not permitted" + } + ConfigLoadError::NameDuplicate(_) => { + "Found duplicate 'name' in some configurations, please use different names" + } + ConfigLoadError::UnableToCreateDefaultConfig => "Could not generate default config file", + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::{NamedTempFile, TempDir}; + use std::fs; + use std::fs::{create_dir_all}; + + const DEFAULT_CONFIG_FILE_CONTENT: &str = include_str!("res/test/default.yml"); + const TEST_WORKING_CONFIG_FILE: &str = include_str!("res/test/working_config.yml"); + const TEST_CONFIG_FILE_WITH_BAD_YAML: &str = include_str!("res/test/config_with_bad_yaml.yml"); + + // Test Configs + + fn create_tmp_file(string: &str) -> NamedTempFile { + let file = NamedTempFile::new().unwrap(); + file.as_file().write_all(string.as_bytes()).unwrap(); + file + } + + #[test] + fn test_config_file_not_found() { + let config = Configs::load_config(Path::new("invalid/path")); + assert_eq!(config.is_err(), true); + assert_eq!(config.unwrap_err(), ConfigLoadError::FileNotFound); + } + + #[test] + fn test_config_file_with_bad_yaml_syntax() { + let broken_config_file = create_tmp_file(TEST_CONFIG_FILE_WITH_BAD_YAML); + let config = Configs::load_config(broken_config_file.path()); + match config { + Ok(_) => assert!(false), + Err(e) => { + match e { + ConfigLoadError::InvalidYAML(p, _) => assert_eq!(p, broken_config_file.path().to_owned()), + _ => assert!(false), + } + assert!(true); + } + } + } + + #[test] + fn test_validate_field_macro() { + let mut result = true; + + validate_field!(result, 3, 3); + assert_eq!(result, true); + + validate_field!(result, 10, 3); + assert_eq!(result, false); + + validate_field!(result, 3, 3); + assert_eq!(result, false); + } + + #[test] + fn test_user_defined_config_does_not_have_reserved_fields() { + let working_config_file = create_tmp_file( + r###" + + backend: Clipboard + + "###, + ); + let config = Configs::load_config(working_config_file.path()); + assert_eq!(config.unwrap().validate_user_defined_config(), true); + } + + #[test] + fn test_user_defined_config_has_reserved_fields_config_caching_interval() { + let working_config_file = create_tmp_file( + r###" + + # This should not happen in an app-specific config + config_caching_interval: 100 + + "###, + ); + let config = Configs::load_config(working_config_file.path()); + assert_eq!(config.unwrap().validate_user_defined_config(), false); + } + + #[test] + fn test_user_defined_config_has_reserved_fields_toggle_key() { + let working_config_file = create_tmp_file( + r###" + + # This should not happen in an app-specific config + toggle_key: CTRL + + "###, + ); + let config = Configs::load_config(working_config_file.path()); + assert_eq!(config.unwrap().validate_user_defined_config(), false); + } + + #[test] + fn test_user_defined_config_has_reserved_fields_toggle_interval() { + let working_config_file = create_tmp_file( + r###" + + # This should not happen in an app-specific config + toggle_interval: 1000 + + "###, + ); + let config = Configs::load_config(working_config_file.path()); + assert_eq!(config.unwrap().validate_user_defined_config(), false); + } + + #[test] + fn test_user_defined_config_has_reserved_fields_backspace_limit() { + let working_config_file = create_tmp_file( + r###" + + # This should not happen in an app-specific config + backspace_limit: 10 + + "###, + ); + let config = Configs::load_config(working_config_file.path()); + assert_eq!(config.unwrap().validate_user_defined_config(), false); + } + + #[test] + fn test_config_loaded_correctly() { + let working_config_file = create_tmp_file(TEST_WORKING_CONFIG_FILE); + let config = Configs::load_config(working_config_file.path()); + assert_eq!(config.is_ok(), true); + } + + // Test ConfigSet + + pub fn create_temp_espanso_directories() -> (TempDir, TempDir) { + create_temp_espanso_directories_with_default_content(DEFAULT_CONFIG_FILE_CONTENT) + } + + pub fn create_temp_espanso_directories_with_default_content( + default_content: &str, + ) -> (TempDir, TempDir) { + let data_dir = TempDir::new().expect("unable to create data directory"); + let package_dir = TempDir::new().expect("unable to create package directory"); + + let default_path = data_dir.path().join(DEFAULT_CONFIG_FILE_NAME); + fs::write(default_path, default_content).unwrap(); + + (data_dir, package_dir) + } + + pub fn create_temp_file_in_dir(tmp_dir: &PathBuf, name: &str, content: &str) -> PathBuf { + let user_defined_path = tmp_dir.join(name); + let user_defined_path_copy = user_defined_path.clone(); + fs::write(user_defined_path, content).unwrap(); + + user_defined_path_copy + } + + pub fn create_user_config_file(tmp_dir: &Path, name: &str, content: &str) -> PathBuf { + let user_config_dir = tmp_dir.join(USER_CONFIGS_FOLDER_NAME); + if !user_config_dir.exists() { + create_dir_all(&user_config_dir).unwrap(); + } + + create_temp_file_in_dir(&user_config_dir, name, content) + } + + pub fn create_package_file( + package_data_dir: &Path, + package_name: &str, + filename: &str, + content: &str, + ) -> PathBuf { + let package_dir = package_data_dir.join(package_name); + if !package_dir.exists() { + create_dir_all(&package_dir).unwrap(); + } + + create_temp_file_in_dir(&package_dir, filename, content) + } + + #[test] + fn test_config_set_default_content_should_work_correctly() { + let (data_dir, package_dir) = create_temp_espanso_directories(); + + let config_set = ConfigSet::load(data_dir.path(), package_dir.path()); + assert!(config_set.is_ok()); + } + + #[test] + fn test_config_set_load_fail_bad_directory() { + let config_set = ConfigSet::load(Path::new("invalid/path"), Path::new("invalid/path")); + assert_eq!(config_set.is_err(), true); + assert_eq!( + config_set.unwrap_err(), + ConfigLoadError::InvalidConfigDirectory + ); + } + + #[test] + fn test_config_set_missing_default_file() { + let data_dir = TempDir::new().expect("unable to create temp directory"); + let package_dir = TempDir::new().expect("unable to create package directory"); + + let config_set = ConfigSet::load(data_dir.path(), package_dir.path()); + assert_eq!(config_set.is_err(), true); + assert_eq!(config_set.unwrap_err(), ConfigLoadError::FileNotFound); + } + + #[test] + fn test_config_set_invalid_yaml_syntax() { + let (data_dir, package_dir) = + create_temp_espanso_directories_with_default_content(TEST_CONFIG_FILE_WITH_BAD_YAML); + let default_path = data_dir.path().join(DEFAULT_CONFIG_FILE_NAME); + + let config_set = ConfigSet::load(data_dir.path(), package_dir.path()); + match config_set { + Ok(_) => assert!(false), + Err(e) => { + match e { + ConfigLoadError::InvalidYAML(p, _) => assert_eq!(p, default_path), + _ => assert!(false), + } + assert!(true); + } + } + } + + #[test] + fn test_config_set_specific_file_with_reserved_fields() { + let (data_dir, package_dir) = create_temp_espanso_directories(); + + let user_defined_path = create_user_config_file( + data_dir.path(), + "specific.yml", + r###" + config_caching_interval: 10000 + "###, + ); + let user_defined_path_copy = user_defined_path.clone(); + + let config_set = ConfigSet::load(data_dir.path(), package_dir.path()); + assert!(config_set.is_err()); + assert_eq!( + config_set.unwrap_err(), + ConfigLoadError::InvalidParameter(user_defined_path_copy) + ) + } + + #[test] + fn test_config_set_specific_file_missing_name_auto_generated() { + let (data_dir, package_dir) = create_temp_espanso_directories(); + + let user_defined_path = create_user_config_file( + data_dir.path(), + "specific.yml", + r###" + backend: Clipboard + "###, + ); + let user_defined_path_copy = user_defined_path.clone(); + + let config_set = ConfigSet::load(data_dir.path(), package_dir.path()); + assert!(config_set.is_ok()); + assert_eq!( + config_set.unwrap().specific[0].name, + user_defined_path_copy.to_str().unwrap_or_default() + ) + } + + #[test] + fn test_config_set_specific_file_duplicate_name() { + let (data_dir, package_dir) = create_temp_espanso_directories(); + + create_user_config_file( + data_dir.path(), + "specific.yml", + r###" + name: specific1 + "###, + ); + + create_user_config_file( + data_dir.path(), + "specific2.yml", + r###" + name: specific1 + "###, + ); + + let config_set = ConfigSet::load(data_dir.path(), package_dir.path()); + assert!(config_set.is_err()); + assert!(matches!( + &config_set.unwrap_err(), + &ConfigLoadError::NameDuplicate(_) + )) + } + + #[test] + fn test_user_defined_config_set_merge_with_parent_matches() { + let (data_dir, package_dir) = create_temp_espanso_directories_with_default_content( + r###" + matches: + - trigger: ":lol" + replace: "LOL" + - trigger: ":yess" + replace: "Bob" + "###, + ); + + create_user_config_file( + data_dir.path(), + "specific1.yml", + r###" + name: specific1 + + matches: + - trigger: "hello" + replace: "newstring" + "###, + ); + + let config_set = ConfigSet::load(data_dir.path(), package_dir.path()).unwrap(); + assert_eq!(config_set.default.matches.len(), 2); + assert_eq!(config_set.specific[0].matches.len(), 3); + + assert!(config_set.specific[0] + .matches + .iter() + .find(|x| triggers_for_match(x)[0] == "hello") + .is_some()); + assert!(config_set.specific[0] + .matches + .iter() + .find(|x| triggers_for_match(x)[0] == ":lol") + .is_some()); + assert!(config_set.specific[0] + .matches + .iter() + .find(|x| triggers_for_match(x)[0] == ":yess") + .is_some()); + } + + #[test] + fn test_user_defined_config_set_merge_with_parent_matches_child_priority() { + let (data_dir, package_dir) = create_temp_espanso_directories_with_default_content( + r###" + matches: + - trigger: ":lol" + replace: "LOL" + - trigger: ":yess" + replace: "Bob" + "###, + ); + + create_user_config_file( + data_dir.path(), + "specific2.yml", + r###" + name: specific1 + + matches: + - trigger: ":lol" + replace: "newstring" + "###, + ); + + let config_set = ConfigSet::load(data_dir.path(), package_dir.path()).unwrap(); + assert_eq!(config_set.default.matches.len(), 2); + assert_eq!(config_set.specific[0].matches.len(), 2); + + assert!(config_set.specific[0] + .matches + .iter() + .find(|x| { + triggers_for_match(x)[0] == ":lol" && replace_for_match(x) == "newstring" + }) + .is_some()); + assert!(config_set.specific[0] + .matches + .iter() + .find(|x| triggers_for_match(x)[0] == ":yess") + .is_some()); + } + + #[test] + fn test_user_defined_config_set_exclude_merge_with_parent_matches() { + let (data_dir, package_dir) = create_temp_espanso_directories_with_default_content( + r###" + matches: + - trigger: ":lol" + replace: "LOL" + - trigger: ":yess" + replace: "Bob" + "###, + ); + + create_user_config_file( + data_dir.path(), + "specific2.yml", + r###" + name: specific1 + + exclude_default_entries: true + + matches: + - trigger: "hello" + replace: "newstring" + "###, + ); + + let config_set = ConfigSet::load(data_dir.path(), package_dir.path()).unwrap(); + assert_eq!(config_set.default.matches.len(), 2); + assert_eq!(config_set.specific[0].matches.len(), 1); + + assert!(config_set.specific[0] + .matches + .iter() + .find(|x| { + triggers_for_match(x)[0] == "hello" && replace_for_match(x) == "newstring" + }) + .is_some()); + } + + #[test] + fn test_only_yaml_files_are_loaded_from_config() { + let (data_dir, package_dir) = create_temp_espanso_directories_with_default_content( + r###" + matches: + - trigger: ":lol" + replace: "LOL" + - trigger: ":yess" + replace: "Bob" + "###, + ); + + create_user_config_file( + data_dir.path(), + "specific.zzz", + r###" + name: specific1 + + exclude_default_entries: true + + matches: + - trigger: "hello" + replace: "newstring" + "###, + ); + + let config_set = ConfigSet::load(data_dir.path(), package_dir.path()).unwrap(); + assert_eq!(config_set.specific.len(), 0); + } + + #[test] + fn test_hidden_files_are_ignored() { + let (data_dir, package_dir) = create_temp_espanso_directories_with_default_content( + r###" + matches: + - trigger: ":lol" + replace: "LOL" + - trigger: ":yess" + replace: "Bob" + "###, + ); + + create_user_config_file( + data_dir.path(), + ".specific.yml", + r###" + name: specific1 + + exclude_default_entries: true + + matches: + - trigger: "hello" + replace: "newstring" + "###, + ); + + let config_set = ConfigSet::load(data_dir.path(), package_dir.path()).unwrap(); + assert_eq!(config_set.specific.len(), 0); + } + + #[test] + fn test_config_set_no_parent_configs_works_correctly() { + let (data_dir, package_dir) = create_temp_espanso_directories(); + + create_user_config_file( + data_dir.path(), + "specific.yml", + r###" + name: specific1 + "###, + ); + + create_user_config_file( + data_dir.path(), + "specific2.yml", + r###" + name: specific2 + "###, + ); + + let config_set = ConfigSet::load(data_dir.path(), package_dir.path()).unwrap(); + assert_eq!(config_set.specific.len(), 2); + } + + #[test] + fn test_config_set_default_parent_works_correctly() { + let (data_dir, package_dir) = create_temp_espanso_directories_with_default_content( + r###" + matches: + - trigger: hasta + replace: Hasta la vista + "###, + ); + + create_user_config_file( + data_dir.path(), + "specific.yml", + r###" + parent: default + + matches: + - trigger: "hello" + replace: "world" + "###, + ); + + let config_set = ConfigSet::load(data_dir.path(), package_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| triggers_for_match(m)[0] == "hasta")); + assert!(config_set + .default + .matches + .iter() + .any(|m| triggers_for_match(m)[0] == "hello")); + } + + #[test] + fn test_config_set_no_parent_should_not_merge() { + let (data_dir, package_dir) = create_temp_espanso_directories_with_default_content( + r###" + matches: + - trigger: hasta + replace: Hasta la vista + "###, + ); + + create_user_config_file( + data_dir.path(), + "specific.yml", + r###" + matches: + - trigger: "hello" + replace: "world" + "###, + ); + + let config_set = ConfigSet::load(data_dir.path(), package_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| triggers_for_match(m)[0] == "hasta")); + assert!(!config_set + .default + .matches + .iter() + .any(|m| triggers_for_match(m)[0] == "hello")); + assert!(config_set.specific[0] + .matches + .iter() + .any(|m| triggers_for_match(m)[0] == "hello")); + } + + #[test] + fn test_config_set_default_nested_parent_works_correctly() { + let (data_dir, package_dir) = create_temp_espanso_directories_with_default_content( + r###" + matches: + - trigger: hasta + replace: Hasta la vista + "###, + ); + + create_user_config_file( + data_dir.path(), + "specific.yml", + r###" + name: custom1 + parent: default + + matches: + - trigger: "hello" + replace: "world" + "###, + ); + + create_user_config_file( + data_dir.path(), + "specific2.yml", + r###" + parent: custom1 + + matches: + - trigger: "super" + replace: "mario" + "###, + ); + + let config_set = ConfigSet::load(data_dir.path(), package_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| triggers_for_match(m)[0] == "hasta")); + assert!(config_set + .default + .matches + .iter() + .any(|m| triggers_for_match(m)[0] == "hello")); + assert!(config_set + .default + .matches + .iter() + .any(|m| triggers_for_match(m)[0] == "super")); + } + + #[test] + fn test_config_set_parent_merge_children_priority_should_be_higher() { + let (data_dir, package_dir) = create_temp_espanso_directories_with_default_content( + r###" + matches: + - trigger: hasta + replace: Hasta la vista + "###, + ); + + create_user_config_file( + data_dir.path(), + "specific.yml", + r###" + parent: default + + matches: + - trigger: "hasta" + replace: "world" + "###, + ); + + let config_set = ConfigSet::load(data_dir.path(), package_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| { + triggers_for_match(m)[0] == "hasta" && replace_for_match(m) == "world" + })); + } + + #[test] + fn test_config_set_package_configs_default_merge() { + let (data_dir, package_dir) = create_temp_espanso_directories_with_default_content( + r###" + matches: + - trigger: hasta + replace: Hasta la vista + "###, + ); + + create_package_file( + package_dir.path(), + "package1", + "package.yml", + r###" + parent: default + + matches: + - trigger: "harry" + replace: "potter" + "###, + ); + + let config_set = ConfigSet::load(data_dir.path(), package_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| triggers_for_match(m)[0] == "hasta")); + assert!(config_set + .default + .matches + .iter() + .any(|m| triggers_for_match(m)[0] == "harry")); + } + + #[test] + fn test_config_set_package_configs_lower_priority_than_user() { + let (data_dir, package_dir) = create_temp_espanso_directories_with_default_content( + r###" + matches: + - trigger: hasta + replace: Hasta la vista + "###, + ); + + create_package_file( + package_dir.path(), + "package1", + "package.yml", + r###" + parent: default + + matches: + - trigger: "hasta" + replace: "potter" + "###, + ); + + let config_set = ConfigSet::load(data_dir.path(), package_dir.path()).unwrap(); + assert_eq!(config_set.specific.len(), 0); + assert_eq!(config_set.default.matches.len(), 1); + assert_eq!(triggers_for_match(&config_set.default.matches[0])[0], "hasta"); + assert_eq!(replace_for_match(&config_set.default.matches[0]), "Hasta la vista"); + } + + #[test] + fn test_config_set_package_configs_without_merge() { + let (data_dir, package_dir) = create_temp_espanso_directories_with_default_content( + r###" + matches: + - trigger: hasta + replace: Hasta la vista + "###, + ); + + create_package_file( + package_dir.path(), + "package1", + "package.yml", + r###" + matches: + - trigger: "harry" + replace: "potter" + "###, + ); + + let config_set = ConfigSet::load(data_dir.path(), package_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| triggers_for_match(m)[0] == "hasta")); + assert!(config_set.specific[0] + .matches + .iter() + .any(|m| triggers_for_match(m)[0] == "harry")); + } + + #[test] + fn test_config_set_package_configs_multiple_files() { + let (data_dir, package_dir) = create_temp_espanso_directories_with_default_content( + r###" + matches: + - trigger: hasta + replace: Hasta la vista + "###, + ); + + create_package_file( + package_dir.path(), + "package1", + "package.yml", + r###" + name: package1 + + matches: + - trigger: "harry" + replace: "potter" + "###, + ); + + create_package_file( + package_dir.path(), + "package1", + "addon.yml", + r###" + parent: package1 + + matches: + - trigger: "ron" + replace: "weasley" + "###, + ); + + let config_set = ConfigSet::load(data_dir.path(), package_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| triggers_for_match(m)[0] == "hasta")); + assert!(config_set.specific[0] + .matches + .iter() + .any(|m| triggers_for_match(m)[0] == "harry")); + assert!(config_set.specific[0] + .matches + .iter() + .any(|m| triggers_for_match(m)[0] == "ron")); + } + + #[test] + fn test_list_has_conflict_no_conflict() { + assert_eq!( + ConfigSet::list_has_conflicts(&vec!(":ab".to_owned(), ":bc".to_owned())), + false + ); + } + + #[test] + fn test_list_has_conflict_conflict() { + let mut list = vec!["ac".to_owned(), "ab".to_owned(), "abc".to_owned()]; + list.sort(); + assert_eq!(ConfigSet::list_has_conflicts(&list), true); + } + + #[test] + fn test_has_conflict_no_conflict() { + let (data_dir, package_dir) = create_temp_espanso_directories_with_default_content( + r###" + matches: + - trigger: ac + replace: Hasta la vista + - trigger: bc + replace: Jon + "###, + ); + + create_user_config_file( + data_dir.path(), + "specific.yml", + r###" + name: specific1 + + matches: + - trigger: "hello" + replace: "world" + "###, + ); + + let config_set = ConfigSet::load(data_dir.path(), package_dir.path()).unwrap(); + assert_eq!( + ConfigSet::has_conflicts(&config_set.default, &config_set.specific), + false + ); + } + + #[test] + fn test_has_conflict_conflict_in_default() { + let (data_dir, package_dir) = create_temp_espanso_directories_with_default_content( + r###" + matches: + - trigger: ac + replace: Hasta la vista + - trigger: bc + replace: Jon + - trigger: acb + replace: Error + "###, + ); + + create_user_config_file( + data_dir.path(), + "specific.yml", + r###" + name: specific1 + + matches: + - trigger: "hello" + replace: "world" + "###, + ); + + let config_set = ConfigSet::load(data_dir.path(), package_dir.path()).unwrap(); + assert_eq!( + ConfigSet::has_conflicts(&config_set.default, &config_set.specific), + true + ); + } + + #[test] + fn test_has_conflict_conflict_in_specific_and_default() { + let (data_dir, package_dir) = create_temp_espanso_directories_with_default_content( + r###" + matches: + - trigger: ac + replace: Hasta la vista + - trigger: bc + replace: Jon + "###, + ); + + create_user_config_file( + data_dir.path(), + "specific.yml", + r###" + name: specific1 + + matches: + - trigger: "bcd" + replace: "Conflict" + "###, + ); + + let config_set = ConfigSet::load(data_dir.path(), package_dir.path()).unwrap(); + assert_eq!( + ConfigSet::has_conflicts(&config_set.default, &config_set.specific), + true + ); + } + + #[test] + fn test_has_conflict_no_conflict_in_specific_and_specific() { + let (data_dir, package_dir) = create_temp_espanso_directories_with_default_content( + r###" + matches: + - trigger: ac + replace: Hasta la vista + - trigger: bc + replace: Jon + "###, + ); + + create_user_config_file( + data_dir.path(), + "specific.yml", + r###" + name: specific1 + + matches: + - trigger: "bad" + replace: "Conflict" + "###, + ); + create_user_config_file( + data_dir.path(), + "specific2.yml", + r###" + name: specific2 + + matches: + - trigger: "badass" + replace: "Conflict" + "###, + ); + + let config_set = ConfigSet::load(data_dir.path(), package_dir.path()).unwrap(); + assert_eq!( + ConfigSet::has_conflicts(&config_set.default, &config_set.specific), + false + ); + } + + #[test] + fn test_config_set_specific_inherits_default_global_vars() { + let (data_dir, package_dir) = create_temp_espanso_directories_with_default_content( + r###" + global_vars: + - name: testvar + type: date + params: + format: "%m" + "###, + ); + + create_user_config_file( + data_dir.path(), + "specific.yml", + r###" + global_vars: + - name: specificvar + type: date + params: + format: "%m" + "###, + ); + + let config_set = ConfigSet::load(data_dir.path(), package_dir.path()).unwrap(); + assert_eq!(config_set.specific.len(), 1); + assert_eq!(config_set.default.global_vars.len(), 1); + assert_eq!(config_set.specific[0].global_vars.len(), 2); + assert!(config_set.specific[0] + .global_vars + .iter() + .any(|m| name_for_global_var(m) == "testvar")); + assert!(config_set.specific[0] + .global_vars + .iter() + .any(|m| name_for_global_var(m) == "specificvar")); + } + + #[test] + fn test_config_set_default_get_variables_from_specific() { + let (data_dir, package_dir) = create_temp_espanso_directories_with_default_content( + r###" + global_vars: + - name: testvar + type: date + params: + format: "%m" + "###, + ); + + create_user_config_file( + data_dir.path(), + "specific.yml", + r###" + parent: default + global_vars: + - name: specificvar + type: date + params: + format: "%m" + "###, + ); + + let config_set = ConfigSet::load(data_dir.path(), package_dir.path()).unwrap(); + assert_eq!(config_set.specific.len(), 0); + assert_eq!(config_set.default.global_vars.len(), 2); + assert!(config_set + .default + .global_vars + .iter() + .any(|m| name_for_global_var(m) == "testvar")); + assert!(config_set + .default + .global_vars + .iter() + .any(|m| name_for_global_var(m) == "specificvar")); + } + + #[test] + fn test_config_set_specific_dont_inherits_default_global_vars_when_exclude_is_on() { + let (data_dir, package_dir) = create_temp_espanso_directories_with_default_content( + r###" + global_vars: + - name: testvar + type: date + params: + format: "%m" + "###, + ); + + create_user_config_file( + data_dir.path(), + "specific.yml", + r###" + exclude_default_entries: true + + global_vars: + - name: specificvar + type: date + params: + format: "%m" + "###, + ); + + let config_set = ConfigSet::load(data_dir.path(), package_dir.path()).unwrap(); + assert_eq!(config_set.specific.len(), 1); + assert_eq!(config_set.default.global_vars.len(), 1); + assert_eq!(config_set.specific[0].global_vars.len(), 1); + assert!(config_set.specific[0] + .global_vars + .iter() + .any(|m| name_for_global_var(m) == "specificvar")); + } +} diff --git a/espanso-config/src/legacy/mod.rs b/espanso-config/src/legacy/mod.rs new file mode 100644 index 0000000..31db2f7 --- /dev/null +++ b/espanso-config/src/legacy/mod.rs @@ -0,0 +1,32 @@ +/* + * This file is part of espanso. + * + * Copyright (C) 2019-2021 Federico Terzi + * + * espanso is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * espanso is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with espanso. If not, see . + */ + +use std::path::Path; +use anyhow::Result; + +use crate::{config::ConfigStore, matches::store::MatchStore}; + +mod config; +mod model; + +pub fn load(base_dir: &Path) -> Result<(Box, Box)> { + // TODO: load legacy config set and then convert it to the new format + + todo!() +} \ No newline at end of file diff --git a/espanso-config/src/legacy/model.rs b/espanso-config/src/legacy/model.rs new file mode 100644 index 0000000..f95ed31 --- /dev/null +++ b/espanso-config/src/legacy/model.rs @@ -0,0 +1,61 @@ +/* + * This file is part of espanso. + * + * Copyright (C) 2019-2021 Federico Terzi + * + * espanso is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * espanso is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with espanso. If not, see . + */ + +use serde::{Deserialize, Serialize}; + +#[allow(non_camel_case_types)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum KeyModifier { + CTRL, + SHIFT, + ALT, + META, + BACKSPACE, + OFF, + + // These are specific variants of the ones above. See issue: #117 + // https://github.com/federico-terzi/espanso/issues/117 + LEFT_CTRL, + RIGHT_CTRL, + LEFT_ALT, + RIGHT_ALT, + LEFT_META, + RIGHT_META, + LEFT_SHIFT, + RIGHT_SHIFT, + + // Special cases, should not be used in config + CAPS_LOCK, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub enum PasteShortcut { + Default, // Default one for the current system + CtrlV, // Classic Ctrl+V shortcut + CtrlShiftV, // Could be used to paste without formatting in many applications + ShiftInsert, // Often used in Linux systems + CtrlAltV, // Used in some Linux terminals (urxvt) + MetaV, // Corresponding to Win+V on Windows and Linux, CMD+V on macOS +} + +impl Default for PasteShortcut { + fn default() -> Self { + PasteShortcut::Default + } +} diff --git a/espanso-config/src/legacy/res/test/config_with_bad_yaml.yml b/espanso-config/src/legacy/res/test/config_with_bad_yaml.yml new file mode 100644 index 0000000..b5d6822 --- /dev/null +++ b/espanso-config/src/legacy/res/test/config_with_bad_yaml.yml @@ -0,0 +1,12 @@ +backend: Clipboard + +definitely a bad yaml + +matches: + # Default + - trigger: ":espanso" + replace: "Hi there!" + + # Emojis + - trigger: ":lol" + replace: "😂" \ No newline at end of file diff --git a/espanso-config/src/legacy/res/test/default.yml b/espanso-config/src/legacy/res/test/default.yml new file mode 100644 index 0000000..7ad3e30 --- /dev/null +++ b/espanso-config/src/legacy/res/test/default.yml @@ -0,0 +1,30 @@ +# espanso configuration file + +# This is the default configuration file, change it as you like it +# You can refer to the official documentation: +# https://espanso.org/docs/ + +# Matches are the substitution rules, when you type the "trigger" string +# it gets replaced by the "replace" string. +matches: + # Simple text replacement + - trigger: ":espanso" + replace: "Hi there!" + + # Dates + - trigger: ":date" + replace: "{{mydate}}" + vars: + - name: mydate + type: date + params: + format: "%m/%d/%Y" + + # Shell commands + - trigger: ":shell" + replace: "{{output}}" + vars: + - name: output + type: shell + params: + cmd: "echo Hello from your shell" \ No newline at end of file diff --git a/espanso-config/src/legacy/res/test/working_config.yml b/espanso-config/src/legacy/res/test/working_config.yml new file mode 100644 index 0000000..bcf8bf6 --- /dev/null +++ b/espanso-config/src/legacy/res/test/working_config.yml @@ -0,0 +1,10 @@ +backend: Clipboard + +matches: + # Default + - trigger: ":espanso" + replace: "Hi there!" + + # Emojis + - trigger: ":lol" + replace: "😂" \ No newline at end of file diff --git a/espanso-config/src/lib.rs b/espanso-config/src/lib.rs index 33e19de..e049134 100644 --- a/espanso-config/src/lib.rs +++ b/espanso-config/src/lib.rs @@ -28,6 +28,7 @@ extern crate lazy_static; pub mod config; mod counter; +mod legacy; pub mod matches; mod util; @@ -40,12 +41,15 @@ pub fn load(base_path: &Path) -> Result<(impl ConfigStore, impl MatchStore)> { let config_store = config::load_store(&config_dir)?; let root_paths = config_store.get_all_match_paths(); - let mut match_store = matches::store::new(); - match_store.load(&root_paths.into_iter().collect::>()); + let match_store = matches::store::new(&root_paths.into_iter().collect::>()); Ok((config_store, match_store)) } +pub fn is_legacy_config(base_dir: &Path) -> bool { + !base_dir.join("config").is_dir() && !base_dir.join("match").is_dir() +} + #[derive(Error, Debug)] pub enum ConfigError { #[error("missing config directory")] diff --git a/espanso-config/src/matches/store/default.rs b/espanso-config/src/matches/store/default.rs index 2be0eb1..8b3ea6a 100644 --- a/espanso-config/src/matches/store/default.rs +++ b/espanso-config/src/matches/store/default.rs @@ -34,21 +34,21 @@ pub(crate) struct DefaultMatchStore { } impl DefaultMatchStore { - pub fn new() -> Self { + pub fn new(paths: &[String]) -> Self { + let mut groups = HashMap::new(); + + // Because match groups can imports other match groups, + // we have to load them recursively starting from the + // top-level ones. + load_match_groups_recursively(&mut groups, paths); + Self { - groups: HashMap::new(), + groups, } } } impl MatchStore for DefaultMatchStore { - fn load(&mut self, paths: &[String]) { - // Because match groups can imports other match groups, - // we have to load them recursively starting from the - // top-level ones. - load_match_groups_recursively(&mut self.groups, paths); - } - fn query(&self, paths: &[String]) -> MatchSet { let mut matches: Vec<&Match> = Vec::new(); let mut global_vars: Vec<&Variable> = Vec::new(); @@ -223,9 +223,7 @@ mod tests { ) .unwrap(); - let mut match_store = DefaultMatchStore::new(); - - match_store.load(&[base_file.to_string_lossy().to_string()]); + let match_store = DefaultMatchStore::new(&[base_file.to_string_lossy().to_string()]); assert_eq!(match_store.groups.len(), 3); @@ -305,9 +303,7 @@ mod tests { ) .unwrap(); - let mut match_store = DefaultMatchStore::new(); - - match_store.load(&[base_file.to_string_lossy().to_string()]); + let match_store = DefaultMatchStore::new(&[base_file.to_string_lossy().to_string()]); assert_eq!(match_store.groups.len(), 3); }); @@ -368,9 +364,7 @@ mod tests { ) .unwrap(); - let mut match_store = DefaultMatchStore::new(); - - match_store.load(&[base_file.to_string_lossy().to_string()]); + let match_store = DefaultMatchStore::new(&[base_file.to_string_lossy().to_string()]); let match_set = match_store.query(&[base_file.to_string_lossy().to_string()]); @@ -457,9 +451,7 @@ mod tests { ) .unwrap(); - let mut match_store = DefaultMatchStore::new(); - - match_store.load(&[base_file.to_string_lossy().to_string()]); + let match_store = DefaultMatchStore::new(&[base_file.to_string_lossy().to_string()]); let match_set = match_store.query(&[base_file.to_string_lossy().to_string()]); @@ -540,12 +532,7 @@ mod tests { ) .unwrap(); - let mut match_store = DefaultMatchStore::new(); - - match_store.load(&[ - base_file.to_string_lossy().to_string(), - sub_file.to_string_lossy().to_string(), - ]); + let match_store = DefaultMatchStore::new(&[base_file.to_string_lossy().to_string(), sub_file.to_string_lossy().to_string()]); let match_set = match_store.query(&[ base_file.to_string_lossy().to_string(), @@ -632,9 +619,7 @@ mod tests { ) .unwrap(); - let mut match_store = DefaultMatchStore::new(); - - match_store.load(&[base_file.to_string_lossy().to_string()]); + let match_store = DefaultMatchStore::new(&[base_file.to_string_lossy().to_string()]); let match_set = match_store.query(&[ base_file.to_string_lossy().to_string(), diff --git a/espanso-config/src/matches/store/mod.rs b/espanso-config/src/matches/store/mod.rs index 2872160..edeee9e 100644 --- a/espanso-config/src/matches/store/mod.rs +++ b/espanso-config/src/matches/store/mod.rs @@ -22,7 +22,6 @@ use super::{Match, Variable}; mod default; pub trait MatchStore { - fn load(&mut self, paths: &[String]); fn query(&self, paths: &[String]) -> MatchSet; } @@ -32,8 +31,8 @@ pub struct MatchSet<'a> { pub global_vars: Vec<&'a Variable>, } -pub fn new() -> impl MatchStore { +pub fn new(paths: &[String]) -> impl MatchStore { // TODO: here we can replace the DefaultMatchStore with a caching wrapper // that returns the same response for the given "paths" query - default::DefaultMatchStore::new() + default::DefaultMatchStore::new(paths) } diff --git a/espanso-config/src/util.rs b/espanso-config/src/util.rs index 2c1bf80..2335274 100644 --- a/espanso-config/src/util.rs +++ b/espanso-config/src/util.rs @@ -23,7 +23,7 @@ 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() { + if !trimmed_line.starts_with('#') && !trimmed_line.is_empty() { return false; } }