From 7262727823727776712c560031c3e8864b98df5f Mon Sep 17 00:00:00 2001 From: Federico Terzi Date: Mon, 8 Mar 2021 16:36:16 +0100 Subject: [PATCH] Add config store base implementation and tests --- espanso-config/src/config/mod.rs | 14 ++ espanso-config/src/config/resolve.rs | 298 +++++++++++++++++++++++++-- espanso-config/src/config/store.rs | 103 +++++++++ espanso-config/src/config/util.rs | 42 ++++ espanso-config/src/lib.rs | 37 ---- 5 files changed, 444 insertions(+), 50 deletions(-) create mode 100644 espanso-config/src/config/store.rs diff --git a/espanso-config/src/config/mod.rs b/espanso-config/src/config/mod.rs index be3c512..11826dc 100644 --- a/espanso-config/src/config/mod.rs +++ b/espanso-config/src/config/mod.rs @@ -4,8 +4,22 @@ mod path; mod parse; mod util; mod resolve; +mod store; pub trait Config { fn label(&self) -> &str; fn match_paths(&self) -> &HashSet; + + fn is_match(&self, app: &AppProperties) -> bool; +} + +pub trait ConfigStore<'a> { + fn default(&'a self) -> &'a dyn Config; + fn active(&'a self, app: &AppProperties) -> &'a dyn Config; +} + +pub struct AppProperties<'a> { + pub title: Option<&'a str>, + pub class: Option<&'a str>, + pub exec: Option<&'a str>, } \ No newline at end of file diff --git a/espanso-config/src/config/resolve.rs b/espanso-config/src/config/resolve.rs index 66a4c88..1d13c69 100644 --- a/espanso-config/src/config/resolve.rs +++ b/espanso-config/src/config/resolve.rs @@ -1,6 +1,7 @@ -use super::{parse::ParsedConfig, path::calculate_paths, Config}; +use super::{parse::ParsedConfig, path::calculate_paths, util::os_matches, AppProperties, Config}; use crate::merge; use anyhow::Result; +use regex::Regex; use std::iter::FromIterator; use std::{collections::HashSet, path::Path}; use thiserror::Error; @@ -8,12 +9,17 @@ use thiserror::Error; const STANDARD_INCLUDES: &[&str] = &["../match/**/*.yml"]; const STANDARD_EXCLUDES: &[&str] = &["../match/**/_*.yml"]; -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone)] pub(crate) struct ResolvedConfig { parsed: ParsedConfig, // Generated properties + match_paths: HashSet, + + filter_title: Option, + filter_class: Option, + filter_exec: Option, } impl Default for ResolvedConfig { @@ -21,6 +27,9 @@ impl Default for ResolvedConfig { Self { parsed: Default::default(), match_paths: HashSet::new(), + filter_title: None, + filter_class: None, + filter_exec: None, } } } @@ -33,6 +42,58 @@ impl Config for ResolvedConfig { fn match_paths(&self) -> &HashSet { &self.match_paths } + + fn is_match(&self, app: &AppProperties) -> bool { + if self.parsed.filter_os.is_none() + && self.parsed.filter_title.is_none() + && self.parsed.filter_exec.is_none() + && self.parsed.filter_class.is_none() + { + return false; + } + + let is_os_match = if let Some(filter_os) = self.parsed.filter_os.as_deref() { + os_matches(filter_os) + } else { + true + }; + + let is_title_match = + if let Some(title_regex) = self.filter_title.as_ref() { + if let Some(title) = app.title { + title_regex.is_match(title) + } else { + false + } + } else { + true + }; + + let is_exec_match = + if let Some(exec_regex) = self.filter_exec.as_ref() { + if let Some(exec) = app.exec { + exec_regex.is_match(exec) + } else { + false + } + } else { + true + }; + + let is_class_match = + if let Some(class_regex) = self.filter_class.as_ref() { + if let Some(class) = app.class { + class_regex.is_match(class) + } else { + false + } + } else { + true + }; + + // All the filters that have been specified must be true to define a match + is_os_match && is_exec_match && is_title_match && is_class_match + } } impl ResolvedConfig { @@ -47,13 +108,34 @@ impl ResolvedConfig { // Extract the base directory let base_dir = path .parent() - .ok_or_else(|| ResolveError::ParentResolveFailed())?; + .ok_or_else(ResolveError::ParentResolveFailed)?; let match_paths = Self::generate_match_paths(&config, base_dir); + let filter_title = if let Some(filter_title) = config.filter_title.as_deref() { + Some(Regex::new(filter_title)?) + } else { + None + }; + + let filter_class = if let Some(filter_class) = config.filter_class.as_deref() { + Some(Regex::new(filter_class)?) + } else { + None + }; + + let filter_exec = if let Some(filter_exec) = config.filter_exec.as_deref() { + Some(Regex::new(filter_exec)?) + } else { + None + }; + Ok(Self { parsed: config, match_paths, + filter_title, + filter_class, + filter_exec, }) } @@ -210,9 +292,13 @@ mod tests { ..Default::default() }), HashSet::from_iter( - vec!["../match/**/*.yml".to_string(), "custom/*.yml".to_string(), "sub/*.yml".to_string()] - .iter() - .cloned() + vec![ + "../match/**/*.yml".to_string(), + "custom/*.yml".to_string(), + "sub/*.yml".to_string() + ] + .iter() + .cloned() ) ); } @@ -277,9 +363,13 @@ mod tests { ..Default::default() }), HashSet::from_iter( - vec!["../match/**/_*.yml".to_string(), "custom/*.yml".to_string(), "sub/*.yml".to_string()] - .iter() - .cloned() + vec![ + "../match/**/_*.yml".to_string(), + "custom/*.yml".to_string(), + "sub/*.yml".to_string() + ] + .iter() + .cloned() ) ); } @@ -364,16 +454,24 @@ mod tests { // Configs let parent_file = config_dir.join("parent.yml"); - std::fs::write(&parent_file, r#" + std::fs::write( + &parent_file, + r#" excludes: ['../**/another.yml'] - "#).unwrap(); + "#, + ) + .unwrap(); let config_file = config_dir.join("default.yml"); - std::fs::write(&config_file, r#" + std::fs::write( + &config_file, + r#" use_standard_includes: false excludes: [] includes: ["../match/sub/*.yml"] - "#).unwrap(); + "#, + ) + .unwrap(); let parent = ResolvedConfig::load(&parent_file, None).unwrap(); let child = ResolvedConfig::load(&config_file, Some(&parent)).unwrap(); @@ -390,4 +488,178 @@ mod tests { assert_eq!(parent.match_paths(), &expected); }); } + + fn test_filter_is_match(config: &str, app: &AppProperties) -> bool { + let mut result = false; + let result_ref = &mut result; + use_test_directory(move |_, _, config_dir| { + let config_file = config_dir.join("default.yml"); + std::fs::write(&config_file, config).unwrap(); + + let config = ResolvedConfig::load(&config_file, None).unwrap(); + + *result_ref = config.is_match(app) + }); + result + } + + #[test] + fn is_match_no_filters() { + assert!(!test_filter_is_match( + "", + &AppProperties { + title: Some("Google"), + class: Some("Chrome"), + exec: Some("chrome.exe"), + }, + )); + } + + #[test] + fn is_match_filter_title() { + assert!(test_filter_is_match( + "filter_title: Google", + &AppProperties { + title: Some("Google Mail"), + class: Some("Chrome"), + exec: Some("chrome.exe"), + }, + )); + + assert!(!test_filter_is_match( + "filter_title: Google", + &AppProperties { + title: Some("Yahoo"), + class: Some("Chrome"), + exec: Some("chrome.exe"), + }, + )); + + assert!(!test_filter_is_match( + "filter_title: Google", + &AppProperties { + title: None, + class: Some("Chrome"), + exec: Some("chrome.exe"), + }, + )); + } + + #[test] + fn is_match_filter_class() { + assert!(test_filter_is_match( + "filter_class: Chrome", + &AppProperties { + title: Some("Google Mail"), + class: Some("Chrome"), + exec: Some("chrome.exe"), + }, + )); + + assert!(!test_filter_is_match( + "filter_class: Chrome", + &AppProperties { + title: Some("Yahoo"), + class: Some("Another"), + exec: Some("chrome.exe"), + }, + )); + + assert!(!test_filter_is_match( + "filter_class: Chrome", + &AppProperties { + title: Some("google"), + class: None, + exec: Some("chrome.exe"), + }, + )); + } + + #[test] + fn is_match_filter_exec() { + assert!(test_filter_is_match( + "filter_exec: chrome.exe", + &AppProperties { + title: Some("Google Mail"), + class: Some("Chrome"), + exec: Some("chrome.exe"), + }, + )); + + assert!(!test_filter_is_match( + "filter_exec: chrome.exe", + &AppProperties { + title: Some("Yahoo"), + class: Some("Another"), + exec: Some("zoom.exe"), + }, + )); + + assert!(!test_filter_is_match( + "filter_exec: chrome.exe", + &AppProperties { + title: Some("google"), + class: Some("Chrome"), + exec: None, + }, + )); + } + + #[test] + fn is_match_filter_os() { + let (current, another) = if cfg!(target_os = "windows") { + ("windows", "macos") + } else if cfg!(target_os = "macos") { + ("macos", "windows") + } else if cfg!(target_os = "linux") { + ("linux", "macos") + } else { + ("invalid", "invalid") + }; + + assert!(test_filter_is_match( + &format!("filter_os: {}", current), + &AppProperties { + title: Some("Google Mail"), + class: Some("Chrome"), + exec: Some("chrome.exe"), + }, + )); + + assert!(!test_filter_is_match( + &format!("filter_os: {}", another), + &AppProperties { + title: Some("Google Mail"), + class: Some("Chrome"), + exec: Some("chrome.exe"), + }, + )); + } + + #[test] + fn is_match_multiple_filters() { + assert!(test_filter_is_match( + r#" + filter_exec: chrome.exe + filter_title: "Youtube" + "#, + &AppProperties { + title: Some("Youtube - Broadcast Yourself"), + class: Some("Chrome"), + exec: Some("chrome.exe"), + }, + )); + + assert!(!test_filter_is_match( + r#" + filter_exec: chrome.exe + filter_title: "Youtube" + "#, + &AppProperties { + title: Some("Gmail"), + class: Some("Chrome"), + exec: Some("chrome.exe"), + }, + )); + } } diff --git a/espanso-config/src/config/store.rs b/espanso-config/src/config/store.rs new file mode 100644 index 0000000..5eede56 --- /dev/null +++ b/espanso-config/src/config/store.rs @@ -0,0 +1,103 @@ +use super::{Config, ConfigStore}; + +pub(crate) struct DefaultConfigStore { + default: Box, + customs: Vec>, +} + +impl<'a> ConfigStore<'a> for DefaultConfigStore { + fn default(&'a self) -> &'a dyn super::Config { + self.default.as_ref() + } + + fn active(&'a self, app: &super::AppProperties) -> &'a dyn super::Config { + // Find a custom config that matches or fallback to the default one + for custom in self.customs.iter() { + if custom.is_match(app) { + return custom.as_ref(); + } + } + self.default.as_ref() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + struct MockConfig { + label: String, + is_match: bool, + } + + impl MockConfig { + pub fn new(label: &str, is_match: bool) -> Self { + Self { + label: label.to_string(), + is_match, + } + } + } + + impl Config for MockConfig { + fn label(&self) -> &str { + &self.label + } + + fn match_paths(&self) -> &std::collections::HashSet { + unimplemented!() + } + + fn is_match(&self, _: &crate::config::AppProperties) -> bool { + self.is_match + } + } + + #[test] + fn config_store_selects_correctly() { + let default = MockConfig::new("default", false); + let custom1 = MockConfig::new("custom1", false); + let custom2 = MockConfig::new("custom2", true); + + let store = DefaultConfigStore { + default: Box::new(default), + customs: vec![Box::new(custom1), Box::new(custom2)], + }; + + assert_eq!(store.default().label(), "default"); + assert_eq!( + store + .active(&crate::config::AppProperties { + title: None, + class: None, + exec: None, + }) + .label(), + "custom2" + ); + } + + #[test] + fn config_store_active_fallback_to_default_if_no_match() { + let default = MockConfig::new("default", false); + let custom1 = MockConfig::new("custom1", false); + let custom2 = MockConfig::new("custom2", false); + + let store = DefaultConfigStore { + default: Box::new(default), + customs: vec![Box::new(custom1), Box::new(custom2)], + }; + + assert_eq!(store.default().label(), "default"); + assert_eq!( + store + .active(&crate::config::AppProperties { + title: None, + class: None, + exec: None, + }) + .label(), + "default" + ); + } +} diff --git a/espanso-config/src/config/util.rs b/espanso-config/src/config/util.rs index 6f8b975..3ef29fe 100644 --- a/espanso-config/src/config/util.rs +++ b/espanso-config/src/config/util.rs @@ -18,3 +18,45 @@ macro_rules! merge { } }; } + +pub fn os_matches(os: &str) -> bool { + match os { + "macos" => cfg!(target_os = "macos"), + "windows" => cfg!(target_os = "windows"), + "linux" => cfg!(target_os = "linux"), + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + #[cfg(target_os = "linux")] + fn os_matches_linux() { + assert!(os_matches("linux")); + assert!(!os_matches("windows")); + assert!(!os_matches("macos")); + assert!(!os_matches("invalid")); + } + + + #[test] + #[cfg(target_os = "macos")] + fn os_matches_macos() { + assert!(os_matches("macos")); + assert!(!os_matches("windows")); + assert!(!os_matches("linux")); + assert!(!os_matches("invalid")); + } + + #[test] + #[cfg(target_os = "windows")] + fn os_matches_windows() { + assert!(os_matches("windows")); + assert!(!os_matches("macos")); + assert!(!os_matches("linux")); + assert!(!os_matches("invalid")); + } +} \ No newline at end of file diff --git a/espanso-config/src/lib.rs b/espanso-config/src/lib.rs index cb7b70f..d667469 100644 --- a/espanso-config/src/lib.rs +++ b/espanso-config/src/lib.rs @@ -23,40 +23,3 @@ mod util; mod config; mod matches; mod counter; - -use std::path::Path; -use anyhow::Result; -use serde::{Serialize, de::DeserializeOwned}; -use thiserror::Error; -use config::Config; - - -pub struct ConfigSet { - -} - -impl ConfigSet { - //fn active(&self, app: AppProperties) -> &'a Config { - // TODO: using the app properties, check if any of the sub configs match or not. If not, return the default - // Here a RegexSet might be very useful to efficiently match them. - //} - - //fn default(&self) -> &'a Config {} -} - -pub struct AppProperties<'a> { - pub title: Option<&'a str>, - pub class: Option<&'a str>, - pub exec: Option<&'a str>, -} - - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn todo() { - - } -} \ No newline at end of file