Improve config loading process and tests
This commit is contained in:
parent
7262727823
commit
4143caff3d
|
@ -1,4 +1,6 @@
|
||||||
use std::collections::HashSet;
|
use std::{collections::HashSet, path::Path};
|
||||||
|
use thiserror::Error;
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
mod path;
|
mod path;
|
||||||
mod parse;
|
mod parse;
|
||||||
|
@ -8,18 +10,36 @@ mod store;
|
||||||
|
|
||||||
pub trait Config {
|
pub trait Config {
|
||||||
fn label(&self) -> &str;
|
fn label(&self) -> &str;
|
||||||
fn match_paths(&self) -> &HashSet<String>;
|
fn match_paths(&self) -> &[String];
|
||||||
|
|
||||||
fn is_match(&self, app: &AppProperties) -> bool;
|
fn is_match(&self, app: &AppProperties) -> bool;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub trait ConfigStore<'a> {
|
pub trait ConfigStore {
|
||||||
fn default(&'a self) -> &'a dyn Config;
|
fn default(&self) -> &dyn Config;
|
||||||
fn active(&'a self, app: &AppProperties) -> &'a dyn Config;
|
fn active<'a>(&'a self, app: &AppProperties) -> &'a dyn Config;
|
||||||
|
|
||||||
|
fn get_all_match_paths(&self) -> HashSet<String>;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct AppProperties<'a> {
|
pub struct AppProperties<'a> {
|
||||||
pub title: Option<&'a str>,
|
pub title: Option<&'a str>,
|
||||||
pub class: Option<&'a str>,
|
pub class: Option<&'a str>,
|
||||||
pub exec: Option<&'a str>,
|
pub exec: Option<&'a str>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_store(config_dir: &Path) -> Result<impl ConfigStore> {
|
||||||
|
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),
|
||||||
}
|
}
|
|
@ -15,7 +15,7 @@ pub(crate) struct ResolvedConfig {
|
||||||
|
|
||||||
// Generated properties
|
// Generated properties
|
||||||
|
|
||||||
match_paths: HashSet<String>,
|
match_paths: Vec<String>,
|
||||||
|
|
||||||
filter_title: Option<Regex>,
|
filter_title: Option<Regex>,
|
||||||
filter_class: Option<Regex>,
|
filter_class: Option<Regex>,
|
||||||
|
@ -26,7 +26,7 @@ impl Default for ResolvedConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
parsed: Default::default(),
|
parsed: Default::default(),
|
||||||
match_paths: HashSet::new(),
|
match_paths: Vec::new(),
|
||||||
filter_title: None,
|
filter_title: None,
|
||||||
filter_class: None,
|
filter_class: None,
|
||||||
filter_exec: None,
|
filter_exec: None,
|
||||||
|
@ -39,7 +39,7 @@ impl Config for ResolvedConfig {
|
||||||
self.parsed.label.as_deref().unwrap_or("none")
|
self.parsed.label.as_deref().unwrap_or("none")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn match_paths(&self) -> &HashSet<String> {
|
fn match_paths(&self) -> &[String] {
|
||||||
&self.match_paths
|
&self.match_paths
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -110,7 +110,7 @@ impl ResolvedConfig {
|
||||||
.parent()
|
.parent()
|
||||||
.ok_or_else(ResolveError::ParentResolveFailed)?;
|
.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() {
|
let filter_title = if let Some(filter_title) = config.filter_title.as_deref() {
|
||||||
Some(Regex::new(filter_title)?)
|
Some(Regex::new(filter_title)?)
|
||||||
|
@ -425,12 +425,17 @@ mod tests {
|
||||||
|
|
||||||
let config = ResolvedConfig::load(&config_file, None).unwrap();
|
let config = ResolvedConfig::load(&config_file, None).unwrap();
|
||||||
|
|
||||||
let mut expected = HashSet::new();
|
let mut expected = vec![
|
||||||
expected.insert(base_file.to_string_lossy().to_string());
|
base_file.to_string_lossy().to_string(),
|
||||||
expected.insert(another_file.to_string_lossy().to_string());
|
another_file.to_string_lossy().to_string(),
|
||||||
expected.insert(sub_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 parent = ResolvedConfig::load(&parent_file, None).unwrap();
|
||||||
let child = ResolvedConfig::load(&config_file, Some(&parent)).unwrap();
|
let child = ResolvedConfig::load(&config_file, Some(&parent)).unwrap();
|
||||||
|
|
||||||
let mut expected = HashSet::new();
|
let mut expected = vec![
|
||||||
expected.insert(sub_file.to_string_lossy().to_string());
|
sub_file.to_string_lossy().to_string(),
|
||||||
expected.insert(sub_under_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();
|
let expected = vec![
|
||||||
expected.insert(base_file.to_string_lossy().to_string());
|
base_file.to_string_lossy().to_string()
|
||||||
|
];
|
||||||
|
|
||||||
assert_eq!(parent.match_paths(), &expected);
|
assert_eq!(parent.match_paths(), expected.as_slice());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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 {
|
pub(crate) struct DefaultConfigStore {
|
||||||
default: Box<dyn Config>,
|
default: Box<dyn Config>,
|
||||||
customs: Vec<Box<dyn Config>>,
|
customs: Vec<Box<dyn Config>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> ConfigStore<'a> for DefaultConfigStore {
|
impl ConfigStore for DefaultConfigStore {
|
||||||
fn default(&'a self) -> &'a dyn super::Config {
|
fn default(&self) -> &dyn super::Config {
|
||||||
self.default.as_ref()
|
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
|
// Find a custom config that matches or fallback to the default one
|
||||||
for custom in self.customs.iter() {
|
for custom in self.customs.iter() {
|
||||||
if custom.is_match(app) {
|
if custom.is_match(app) {
|
||||||
|
@ -19,6 +22,64 @@ impl<'a> ConfigStore<'a> for DefaultConfigStore {
|
||||||
}
|
}
|
||||||
self.default.as_ref()
|
self.default.as_ref()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: test
|
||||||
|
fn get_all_match_paths(&self) -> HashSet<String> {
|
||||||
|
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<Self> {
|
||||||
|
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<Box<dyn Config>> = 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)]
|
#[cfg(test)]
|
||||||
|
@ -44,7 +105,7 @@ mod tests {
|
||||||
&self.label
|
&self.label
|
||||||
}
|
}
|
||||||
|
|
||||||
fn match_paths(&self) -> &std::collections::HashSet<String> {
|
fn match_paths(&self) -> &[String] {
|
||||||
unimplemented!()
|
unimplemented!()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -16,10 +16,109 @@
|
||||||
* You should have received a copy of the GNU General Public License
|
* You should have received a copy of the GNU General Public License
|
||||||
* along with espanso. If not, see <https://www.gnu.org/licenses/>.
|
* along with espanso. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
use config::ConfigStore;
|
||||||
|
use matches::store::MatchStore;
|
||||||
|
use anyhow::Result;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
#[macro_use]
|
#[macro_use]
|
||||||
extern crate lazy_static;
|
extern crate lazy_static;
|
||||||
|
|
||||||
mod util;
|
mod util;
|
||||||
mod config;
|
|
||||||
mod matches;
|
|
||||||
mod counter;
|
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::<Vec<String>>());
|
||||||
|
|
||||||
|
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());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -3,7 +3,7 @@ use serde_yaml::Mapping;
|
||||||
use crate::counter::{next_id, StructId};
|
use crate::counter::{next_id, StructId};
|
||||||
|
|
||||||
mod group;
|
mod group;
|
||||||
mod store;
|
pub mod store;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Match {
|
pub struct Match {
|
||||||
|
|
Loading…
Reference in New Issue
Block a user