From 4143caff3df115b6518e53938208e4d9f4674ba9 Mon Sep 17 00:00:00 2001 From: Federico Terzi Date: Mon, 8 Mar 2021 21:46:27 +0100 Subject: [PATCH] Improve config loading process and tests --- espanso-config/src/config/mod.rs | 30 ++++++-- espanso-config/src/config/resolve.rs | 42 ++++++----- espanso-config/src/config/store.rs | 71 ++++++++++++++++-- espanso-config/src/lib.rs | 103 ++++++++++++++++++++++++++- espanso-config/src/matches/mod.rs | 2 +- 5 files changed, 219 insertions(+), 29 deletions(-) diff --git a/espanso-config/src/config/mod.rs b/espanso-config/src/config/mod.rs index 11826dc..1dee966 100644 --- a/espanso-config/src/config/mod.rs +++ b/espanso-config/src/config/mod.rs @@ -1,4 +1,6 @@ -use std::collections::HashSet; +use std::{collections::HashSet, path::Path}; +use thiserror::Error; +use anyhow::Result; mod path; mod parse; @@ -8,18 +10,36 @@ mod store; pub trait Config { fn label(&self) -> &str; - fn match_paths(&self) -> &HashSet; + fn match_paths(&self) -> &[String]; 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 trait ConfigStore { + fn default(&self) -> &dyn Config; + fn active<'a>(&'a self, app: &AppProperties) -> &'a dyn Config; + + fn get_all_match_paths(&self) -> HashSet; } pub struct AppProperties<'a> { pub title: Option<&'a str>, pub class: Option<&'a str>, pub exec: Option<&'a str>, +} + +pub fn load_store(config_dir: &Path) -> Result { + store::DefaultConfigStore::load(config_dir) +} + +#[derive(Error, Debug)] +pub enum ConfigStoreError { + #[error("invalid config directory")] + InvalidConfigDir(), + + #[error("missing default.yml config")] + MissingDefault(), + + #[error("io error")] + IOError(#[from] std::io::Error), } \ No newline at end of file diff --git a/espanso-config/src/config/resolve.rs b/espanso-config/src/config/resolve.rs index 1d13c69..54d6a99 100644 --- a/espanso-config/src/config/resolve.rs +++ b/espanso-config/src/config/resolve.rs @@ -15,7 +15,7 @@ pub(crate) struct ResolvedConfig { // Generated properties - match_paths: HashSet, + match_paths: Vec, filter_title: Option, filter_class: Option, @@ -26,7 +26,7 @@ impl Default for ResolvedConfig { fn default() -> Self { Self { parsed: Default::default(), - match_paths: HashSet::new(), + match_paths: Vec::new(), filter_title: None, filter_class: None, filter_exec: None, @@ -39,7 +39,7 @@ impl Config for ResolvedConfig { self.parsed.label.as_deref().unwrap_or("none") } - fn match_paths(&self) -> &HashSet { + fn match_paths(&self) -> &[String] { &self.match_paths } @@ -110,7 +110,7 @@ impl ResolvedConfig { .parent() .ok_or_else(ResolveError::ParentResolveFailed)?; - let match_paths = Self::generate_match_paths(&config, base_dir); + let match_paths = Self::generate_match_paths(&config, base_dir).into_iter().collect(); let filter_title = if let Some(filter_title) = config.filter_title.as_deref() { Some(Regex::new(filter_title)?) @@ -425,12 +425,17 @@ mod tests { let config = ResolvedConfig::load(&config_file, None).unwrap(); - let mut expected = HashSet::new(); - expected.insert(base_file.to_string_lossy().to_string()); - expected.insert(another_file.to_string_lossy().to_string()); - expected.insert(sub_file.to_string_lossy().to_string()); + let mut expected = vec![ + base_file.to_string_lossy().to_string(), + another_file.to_string_lossy().to_string(), + sub_file.to_string_lossy().to_string(), + ]; + expected.sort(); + + let mut result = config.match_paths().to_vec(); + result.sort(); - assert_eq!(config.match_paths(), &expected); + assert_eq!(result, expected.as_slice()); }); } @@ -476,16 +481,21 @@ mod tests { let parent = ResolvedConfig::load(&parent_file, None).unwrap(); let child = ResolvedConfig::load(&config_file, Some(&parent)).unwrap(); - let mut expected = HashSet::new(); - expected.insert(sub_file.to_string_lossy().to_string()); - expected.insert(sub_under_file.to_string_lossy().to_string()); + let mut expected = vec![ + sub_file.to_string_lossy().to_string(), + sub_under_file.to_string_lossy().to_string(), + ]; + expected.sort(); - assert_eq!(child.match_paths(), &expected); + let mut result = child.match_paths().to_vec(); + result.sort(); + assert_eq!(result, expected.as_slice()); - let mut expected = HashSet::new(); - expected.insert(base_file.to_string_lossy().to_string()); + let expected = vec![ + base_file.to_string_lossy().to_string() + ]; - assert_eq!(parent.match_paths(), &expected); + assert_eq!(parent.match_paths(), expected.as_slice()); }); } diff --git a/espanso-config/src/config/store.rs b/espanso-config/src/config/store.rs index 5eede56..c943a93 100644 --- a/espanso-config/src/config/store.rs +++ b/espanso-config/src/config/store.rs @@ -1,16 +1,19 @@ -use super::{Config, ConfigStore}; +use super::{Config, ConfigStore, ConfigStoreError, resolve::ResolvedConfig}; +use anyhow::Result; +use log::{debug, error}; +use std::{collections::HashSet, path::Path}; pub(crate) struct DefaultConfigStore { default: Box, customs: Vec>, } -impl<'a> ConfigStore<'a> for DefaultConfigStore { - fn default(&'a self) -> &'a dyn super::Config { +impl ConfigStore for DefaultConfigStore { + fn default(&self) -> &dyn super::Config { self.default.as_ref() } - fn active(&'a self, app: &super::AppProperties) -> &'a dyn super::Config { + fn active<'a>(&'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) { @@ -19,6 +22,64 @@ impl<'a> ConfigStore<'a> for DefaultConfigStore { } self.default.as_ref() } + + // TODO: test + fn get_all_match_paths(&self) -> HashSet { + let mut paths = HashSet::new(); + + paths.extend(self.default().match_paths().iter().cloned()); + for custom in self.customs.iter() { + paths.extend(custom.match_paths().iter().cloned()); + } + + paths + } +} + +impl DefaultConfigStore { + // TODO: test + pub fn load(config_dir: &Path) -> Result { + if !config_dir.is_dir() { + return Err(ConfigStoreError::InvalidConfigDir().into()); + } + + // First get the default.yml file + let default_file = config_dir.join("default.yml"); + if !default_file.exists() || !default_file.is_file() { + return Err(ConfigStoreError::MissingDefault().into()); + } + let default = ResolvedConfig::load(&default_file, None)?; + debug!("loaded default config at path: {:?}", default_file); + + // Then the others + let mut customs: Vec> = Vec::new(); + for entry in std::fs::read_dir(config_dir).map_err(ConfigStoreError::IOError)? { + let entry = entry?; + let config_file = entry.path(); + let extension = config_file.extension().unwrap_or_default().to_string_lossy().to_lowercase(); + + // Additional config files are loaded best-effort + if config_file.is_file() + && config_file != default_file + && (extension == "yml" || extension == "yaml") + { + match ResolvedConfig::load(&config_file, Some(&default)) { + Ok(config) => { + customs.push(Box::new(config)); + debug!("loaded config at path: {:?}", config_file); + } + Err(err) => { + error!("unable to load config at path: {:?}, with error: {}", config_file, err); + } + } + } + } + + Ok(Self { + default: Box::new(default), + customs, + }) + } } #[cfg(test)] @@ -44,7 +105,7 @@ mod tests { &self.label } - fn match_paths(&self) -> &std::collections::HashSet { + fn match_paths(&self) -> &[String] { unimplemented!() } diff --git a/espanso-config/src/lib.rs b/espanso-config/src/lib.rs index d667469..b09a2ea 100644 --- a/espanso-config/src/lib.rs +++ b/espanso-config/src/lib.rs @@ -16,10 +16,109 @@ * You should have received a copy of the GNU General Public License * along with espanso. If not, see . */ + +use std::path::Path; +use config::ConfigStore; +use matches::store::MatchStore; +use anyhow::Result; +use thiserror::Error; + #[macro_use] extern crate lazy_static; mod util; -mod config; -mod matches; mod counter; +pub mod config; +pub mod matches; + +pub fn load(base_path: &Path) -> Result<(impl ConfigStore, impl MatchStore)> { + let config_dir = base_path.join("config"); + if !config_dir.exists() || !config_dir.is_dir() { + return Err(ConfigError::MissingConfigDir().into()) + } + + 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::>()); + + Ok((config_store, match_store)) +} + +#[derive(Error, Debug)] +pub enum ConfigError { + #[error("missing config directory")] + MissingConfigDir(), +} + +#[cfg(test)] +mod tests { + use super::*; + use config::{AppProperties, ConfigStore}; + use crate::util::tests::use_test_directory; + + #[test] + fn load_works_correctly() { + use_test_directory(|base, match_dir, config_dir| { + let base_file = match_dir.join("base.yml"); + std::fs::write(&base_file, r#" + matches: + - trigger: "hello" + replace: "world" + "#).unwrap(); + + let another_file = match_dir.join("another.yml"); + std::fs::write(&another_file, r#" + imports: + - "_sub.yml" + + matches: + - trigger: "hello2" + replace: "world2" + "#).unwrap(); + + let under_file = match_dir.join("_sub.yml"); + std::fs::write(&under_file, r#" + matches: + - trigger: "hello3" + replace: "world3" + "#).unwrap(); + + let config_file = config_dir.join("default.yml"); + std::fs::write(&config_file, "").unwrap(); + + let custom_config_file = config_dir.join("custom.yml"); + std::fs::write(&custom_config_file, r#" + filter_title: "Chrome" + + use_standard_includes: false + includes: ["../match/another.yml"] + "#).unwrap(); + + let (config_store, match_store) = load(&base).unwrap(); + + assert_eq!(config_store.default().match_paths().len(), 2); + assert_eq!(config_store.active(&AppProperties { + title: Some("Google Chrome"), + class: None, + exec: None, + }).match_paths().len(), 1); + + assert_eq!(match_store.query(config_store.default().match_paths()).matches.len(), 3); + assert_eq!(match_store.query(config_store.active(&AppProperties { + title: Some("Chrome"), + class: None, + exec: None, + }).match_paths()).matches.len(), 2); + }); + } + + #[test] + fn load_without_valid_config_dir() { + use_test_directory(|_, match_dir, _| { + // To correcly load the configs, the "load" method looks for the "config" directory + assert!(load(&match_dir).is_err()); + }); + } +} diff --git a/espanso-config/src/matches/mod.rs b/espanso-config/src/matches/mod.rs index 23cd181..7fa4c7e 100644 --- a/espanso-config/src/matches/mod.rs +++ b/espanso-config/src/matches/mod.rs @@ -3,7 +3,7 @@ use serde_yaml::Mapping; use crate::counter::{next_id, StructId}; mod group; -mod store; +pub mod store; #[derive(Debug, Clone)] pub struct Match {