Merge pull request #51 from federico-terzi/package

Package manager functionality
This commit is contained in:
Federico Terzi 2019-09-28 00:36:29 +02:00 committed by GitHub
commit d8f433e83c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 2601 additions and 181 deletions

1100
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
[package]
name = "espanso"
version = "0.1.2"
version = "0.2.0"
authors = ["Federico Terzi <federicoterzi96@gmail.com>"]
license = "GPL-3.0"
description = "Cross-platform Text Expander written in Rust"
@ -25,12 +25,13 @@ log-panics = {version = "2.0.0", features = ["with-backtrace"]}
backtrace = "0.3.37"
chrono = "0.4.9"
lazy_static = "1.4.0"
walkdir = "2.2.9"
reqwest = "0.9.20"
git2 = {version = "0.10.1", features = ["https"]}
tempfile = "3.1.0"
[target.'cfg(unix)'.dependencies]
libc = "0.2.62"
[dev-dependencies]
tempfile = "3.1.0"
[build-dependencies]
cmake = "0.1.31"

View File

@ -26,20 +26,24 @@ use std::fs::{File, create_dir_all};
use std::io::Read;
use serde::{Serialize, Deserialize};
use crate::event::KeyModifier;
use std::collections::HashSet;
use std::collections::{HashSet, HashMap};
use log::{error};
use std::fmt;
use std::error::Error;
use walkdir::WalkDir;
pub(crate) mod runtime;
// TODO: add documentation link
const DEFAULT_CONFIG_FILE_CONTENT : &str = include_str!("../res/config.yaml");
const DEFAULT_CONFIG_FILE_CONTENT : &str = include_str!("../res/config.yml");
const DEFAULT_CONFIG_FILE_NAME : &str = "default.yaml";
const DEFAULT_CONFIG_FILE_NAME : &str = "default.yml";
const USER_CONFIGS_FOLDER_NAME: &str = "user";
const PACKAGES_FOLDER_NAME : &str = "packages";
// Default values for primitives
fn default_name() -> String{ "default".to_owned() }
fn default_parent() -> String{ "self".to_owned() }
fn default_filter_title() -> String{ "".to_owned() }
fn default_filter_class() -> String{ "".to_owned() }
fn default_filter_exec() -> String{ "".to_owned() }
@ -50,7 +54,7 @@ fn default_use_system_agent() -> bool { true }
fn default_config_caching_interval() -> i32 { 800 }
fn default_toggle_interval() -> u32 { 230 }
fn default_backspace_limit() -> i32 { 3 }
fn default_exclude_parent_matches() -> bool {false}
fn default_exclude_default_matches() -> bool {false}
fn default_matches() -> Vec<Match> { Vec::new() }
#[derive(Clone, Debug, Serialize, Deserialize)]
@ -58,6 +62,9 @@ pub struct Configs {
#[serde(default = "default_name")]
pub name: String,
#[serde(default = "default_parent")]
pub parent: String,
#[serde(default = "default_filter_title")]
pub filter_title: String,
@ -94,8 +101,8 @@ pub struct Configs {
#[serde(default)]
pub backend: BackendType,
#[serde(default = "default_exclude_parent_matches")]
pub exclude_parent_matches: bool,
#[serde(default = "default_exclude_default_matches")]
pub exclude_default_matches: bool,
#[serde(default = "default_matches")]
pub matches: Vec<Match>
@ -110,7 +117,7 @@ macro_rules! validate_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.yaml config file", field_name);
error!("Validation error, parameter '{}' is reserved and can be only used in the default.yml config file", field_name);
$result = false;
}
};
@ -119,10 +126,10 @@ macro_rules! validate_field {
impl Configs {
/*
* Validate the Config instance.
* It makes sure that app-specific config instances do not define
* It makes sure that user defined config instances do not define
* attributes reserved to the default config.
*/
fn validate_specific_config(&self) -> bool {
fn validate_user_defined_config(&self) -> bool {
let mut result = true;
validate_field!(result, self.config_caching_interval, default_config_caching_interval());
@ -171,6 +178,32 @@ impl Configs {
Err(ConfigLoadError::FileNotFound)
}
}
fn merge_config(&mut self, new_config: Configs) {
let mut merged_matches = new_config.matches;
let mut trigger_set = HashSet::new();
merged_matches.iter().for_each(|m| {
trigger_set.insert(m.trigger.clone());
});
let parent_matches : Vec<Match> = self.matches.iter().filter(|&m| {
!trigger_set.contains(&m.trigger)
}).map(|m| m.clone()).collect();
merged_matches.extend(parent_matches);
self.matches = merged_matches;
}
fn merge_default(&mut self, default: &Configs) {
let mut trigger_set = HashSet::new();
self.matches.iter().for_each(|m| {
trigger_set.insert(m.trigger.clone());
});
let default_matches : Vec<Match> = default.matches.iter().filter(|&m| {
!trigger_set.contains(&m.trigger)
}).map(|m| m.clone()).collect();
self.matches.extend(default_matches);
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
@ -185,70 +218,86 @@ impl ConfigSet {
return Err(ConfigLoadError::InvalidConfigDirectory)
}
// Load default configuration
let default_file = dir_path.join(DEFAULT_CONFIG_FILE_NAME);
let default = Configs::load_config(default_file.as_path())?;
let mut specific = Vec::new();
// Analyze which config files has to be loaded
// Used to make sure no duplicates are present
let mut name_set = HashSet::new();
let mut target_files = Vec::new();
let dir_entry = fs::read_dir(dir_path);
if dir_entry.is_err() {
return Err(ConfigLoadError::UnableToReadFile)
let specific_dir = dir_path.join(USER_CONFIGS_FOLDER_NAME);
if specific_dir.exists() {
let dir_entry = WalkDir::new(specific_dir);
target_files.extend(dir_entry);
}
let dir_entry = dir_entry.unwrap();
for entry in dir_entry {
let entry = entry;
let package_dir = dir_path.join(PACKAGES_FOLDER_NAME);
if package_dir.exists() {
let dir_entry = WalkDir::new(package_dir);
target_files.extend(dir_entry);
}
// Load the user defined config files
let mut name_set = HashSet::new();
let mut children_map: HashMap<String, Vec<Configs>> = HashMap::new();
let mut root_configs = Vec::new();
root_configs.push(default);
for entry in target_files {
if let Ok(entry) = entry {
let path = entry.path();
// Skip the default one, already loaded
if path.file_name().unwrap_or("".as_ref()) == "default.yaml" {
continue;
}
// Skip non-yaml config files
if path.extension().unwrap_or_default().to_str().unwrap_or_default() != "yaml" {
if path.extension().unwrap_or_default().to_str().unwrap_or_default() != "yml" {
continue;
}
let mut config = Configs::load_config(path.as_path())?;
let mut config = Configs::load_config(&path)?;
if !config.validate_specific_config() {
// 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" {
return Err(ConfigLoadError::MissingName(path.to_owned()));
config.name = path.to_str().unwrap_or_default().to_owned();
}
if name_set.contains(&config.name) {
return Err(ConfigLoadError::NameDuplicate(path.to_owned()));
}
// Compute new match set, merging the parent's matches.
// Note: if an app-specific redefines a trigger already present in the
// default config, the latter gets overwritten.
if !config.exclude_parent_matches {
let mut merged_matches = config.matches.clone();
let mut trigger_set = HashSet::new();
merged_matches.iter().for_each(|m| {
trigger_set.insert(m.trigger.clone());
});
let parent_matches : Vec<Match> = default.matches.iter().filter(|&m| {
!trigger_set.contains(&m.trigger)
}).map(|m| m.clone()).collect();
merged_matches.extend(parent_matches);
config.matches = merged_matches;
}
// TODO: check if it contains at least a filter, and warn the user about the problem
name_set.insert(config.name.clone());
specific.push(config);
if config.parent == "self" { // No parent, root config
root_configs.push(config);
}else{ // Children config
let children_vec = children_map.entry(config.parent.clone()).or_default();
children_vec.push(config);
}
}else{
eprintln!("Warning: Unable to read config file: {}", entry.unwrap_err())
}
}
// Merge the children config files
let mut configs = Vec::new();
for root_config in root_configs {
let config = ConfigSet::reduce_configs(root_config, &children_map);
configs.push(config);
}
// Separate default from specific
let default= configs.get(0).unwrap().clone();
let mut specific = (&configs[1..]).to_vec().clone();
// Add default matches to specific configs when needed
for config in specific.iter_mut() {
if !config.exclude_default_matches {
config.merge_default(&default);
}
}
@ -258,31 +307,70 @@ impl ConfigSet {
})
}
pub fn load_default() -> Result<ConfigSet, ConfigLoadError> {
let res = dirs::home_dir();
if let Some(home_dir) = res {
let espanso_dir = home_dir.join(".espanso");
// Create the espanso dir if id doesn't exist
let res = create_dir_all(espanso_dir.as_path());
if let Ok(_) = res {
let default_file = espanso_dir.join(DEFAULT_CONFIG_FILE_NAME);
// If config file does not exist, create one from template
if !default_file.exists() {
let result = fs::write(&default_file, DEFAULT_CONFIG_FILE_CONTENT);
if result.is_err() {
return Err(ConfigLoadError::UnableToCreateDefaultConfig)
}
}
return ConfigSet::load(espanso_dir.as_path())
fn reduce_configs(target: Configs, children_map: &HashMap<String, Vec<Configs>>) -> Configs {
if children_map.contains_key(&target.name) {
let mut target = target;
for children in children_map.get(&target.name).unwrap() {
let children = Self::reduce_configs(children.clone(), children_map);
target.merge_config(children);
}
target
}else{
target
}
}
pub fn load_default() -> Result<ConfigSet, ConfigLoadError> {
let espanso_dir = ConfigSet::get_default_config_dir();
// Create the espanso dir if id doesn't exist
let res = create_dir_all(espanso_dir.as_path());
if let Ok(_) = res {
let default_file = espanso_dir.join(DEFAULT_CONFIG_FILE_NAME);
// If config file does not exist, create one from template
if !default_file.exists() {
let result = fs::write(&default_file, DEFAULT_CONFIG_FILE_CONTENT);
if result.is_err() {
return Err(ConfigLoadError::UnableToCreateDefaultConfig)
}
}
// Create auxiliary directories
let user_config_dir = espanso_dir.join(USER_CONFIGS_FOLDER_NAME);
if !user_config_dir.exists() {
let res = create_dir_all(user_config_dir.as_path());
if res.is_err() {
return Err(ConfigLoadError::UnableToCreateDefaultConfig)
}
}
let packages_dir = espanso_dir.join(PACKAGES_FOLDER_NAME);
if !packages_dir.exists() {
let res = create_dir_all(packages_dir.as_path());
if res.is_err() {
return Err(ConfigLoadError::UnableToCreateDefaultConfig)
}
}
return ConfigSet::load(espanso_dir.as_path())
}
return Err(ConfigLoadError::UnableToCreateDefaultConfig)
}
pub fn get_default_config_dir() -> PathBuf {
let home_dir = dirs::home_dir().expect("Unable to get home directory");
let espanso_dir = home_dir.join(".espanso");
espanso_dir
}
pub fn get_default_packages_dir() -> PathBuf {
let espanso_dir = ConfigSet::get_default_config_dir();
espanso_dir.join(PACKAGES_FOLDER_NAME)
}
}
pub trait ConfigManager<'a> {
@ -299,7 +387,6 @@ pub enum ConfigLoadError {
InvalidYAML(PathBuf, String),
InvalidConfigDirectory,
InvalidParameter(PathBuf),
MissingName(PathBuf),
NameDuplicate(PathBuf),
UnableToCreateDefaultConfig,
}
@ -311,8 +398,7 @@ impl fmt::Display for ConfigLoadError {
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 app-specific configs is not permitted", path.to_str().unwrap_or_default()),
ConfigLoadError::MissingName(path) => write!(f, "The 'name' field is required in app-specific configurations, but it's missing in '{}'", path.to_str().unwrap_or_default()),
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"),
}
@ -326,8 +412,7 @@ impl Error for ConfigLoadError {
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 app-specific configs is not permitted",
ConfigLoadError::MissingName(_) => "The 'name' field is required in app-specific configurations, but it's missing",
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",
}
@ -343,8 +428,8 @@ mod tests {
use tempfile::{NamedTempFile, TempDir};
use std::any::Any;
const TEST_WORKING_CONFIG_FILE : &str = include_str!("../res/test/working_config.yaml");
const TEST_CONFIG_FILE_WITH_BAD_YAML : &str = include_str!("../res/test/config_with_bad_yaml.yaml");
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
@ -397,18 +482,18 @@ mod tests {
}
#[test]
fn test_specific_config_does_not_have_reserved_fields() {
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_specific_config(), true);
assert_eq!(config.unwrap().validate_user_defined_config(), true);
}
#[test]
fn test_specific_config_has_reserved_fields_config_caching_interval() {
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
@ -416,11 +501,11 @@ mod tests {
"###);
let config = Configs::load_config(working_config_file.path());
assert_eq!(config.unwrap().validate_specific_config(), false);
assert_eq!(config.unwrap().validate_user_defined_config(), false);
}
#[test]
fn test_specific_config_has_reserved_fields_toggle_key() {
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
@ -428,11 +513,11 @@ mod tests {
"###);
let config = Configs::load_config(working_config_file.path());
assert_eq!(config.unwrap().validate_specific_config(), false);
assert_eq!(config.unwrap().validate_user_defined_config(), false);
}
#[test]
fn test_specific_config_has_reserved_fields_toggle_interval() {
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
@ -440,11 +525,11 @@ mod tests {
"###);
let config = Configs::load_config(working_config_file.path());
assert_eq!(config.unwrap().validate_specific_config(), false);
assert_eq!(config.unwrap().validate_user_defined_config(), false);
}
#[test]
fn test_specific_config_has_reserved_fields_backspace_limit() {
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
@ -452,7 +537,7 @@ mod tests {
"###);
let config = Configs::load_config(working_config_file.path());
assert_eq!(config.unwrap().validate_specific_config(), false);
assert_eq!(config.unwrap().validate_user_defined_config(), false);
}
#[test]
@ -516,59 +601,80 @@ mod tests {
let default_path = tmp_dir.path().join(DEFAULT_CONFIG_FILE_NAME);
fs::write(default_path, DEFAULT_CONFIG_FILE_CONTENT);
let specific_path = tmp_dir.path().join("specific.yaml");
let specific_path_copy = specific_path.clone();
fs::write(specific_path, r###"
let user_defined_path = create_user_config_file(tmp_dir.path(), "specific.yml", r###"
config_caching_interval: 10000
"###);
let user_defined_path_copy = user_defined_path.clone();
let config_set = ConfigSet::load(tmp_dir.path());
assert!(config_set.is_err());
assert_eq!(config_set.unwrap_err(), ConfigLoadError::InvalidParameter(specific_path_copy))
assert_eq!(config_set.unwrap_err(), ConfigLoadError::InvalidParameter(user_defined_path_copy))
}
#[test]
fn test_config_set_specific_file_missing_name() {
fn test_config_set_specific_file_missing_name_auto_generated() {
let tmp_dir = TempDir::new().expect("unable to create temp directory");
let default_path = tmp_dir.path().join(DEFAULT_CONFIG_FILE_NAME);
fs::write(default_path, DEFAULT_CONFIG_FILE_CONTENT);
let specific_path = tmp_dir.path().join("specific.yaml");
let specific_path_copy = specific_path.clone();
fs::write(specific_path, r###"
let user_defined_path = create_user_config_file(tmp_dir.path(), "specific.yml", r###"
backend: Clipboard
"###);
let user_defined_path_copy = user_defined_path.clone();
let config_set = ConfigSet::load(tmp_dir.path());
assert!(config_set.is_err());
assert_eq!(config_set.unwrap_err(), ConfigLoadError::MissingName(specific_path_copy))
assert!(config_set.is_ok());
assert_eq!(config_set.unwrap().specific[0].name, user_defined_path_copy.to_str().unwrap_or_default())
}
pub fn create_temp_espanso_directory() -> TempDir {
create_temp_espanso_directory_with_default_content(DEFAULT_CONFIG_FILE_CONTENT)
}
pub fn create_temp_espanso_directory_with_default_content(default_content: &str) -> TempDir {
let tmp_dir = TempDir::new().expect("unable to create temp directory");
let default_path = tmp_dir.path().join(DEFAULT_CONFIG_FILE_NAME);
fs::write(default_path, DEFAULT_CONFIG_FILE_CONTENT);
fs::write(default_path, default_content);
tmp_dir
}
pub fn create_temp_file_in_dir(tmp_dir: &TempDir, name: &str, content: &str) -> PathBuf {
let specific_path = tmp_dir.path().join(name);
let specific_path_copy = specific_path.clone();
fs::write(specific_path, content);
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);
specific_path_copy
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);
}
create_temp_file_in_dir(&user_config_dir, name, content)
}
pub fn create_package_file(tmp_dir: &Path, package_name: &str, filename: &str, content: &str) -> PathBuf {
let package_config_dir = tmp_dir.join(PACKAGES_FOLDER_NAME);
let package_dir = package_config_dir.join(package_name);
if !package_dir.exists() {
create_dir_all(&package_dir);
}
create_temp_file_in_dir(&package_dir, filename, content)
}
#[test]
fn test_config_set_specific_file_duplicate_name() {
let tmp_dir = create_temp_espanso_directory();
let specific_path = create_temp_file_in_dir(&tmp_dir, "specific.yaml", r###"
let user_defined_path = create_user_config_file(tmp_dir.path(), "specific.yml", r###"
name: specific1
"###);
let specific_path2 = create_temp_file_in_dir(&tmp_dir, "specific2.yaml", r###"
let user_defined_path2 = create_user_config_file(tmp_dir.path(), "specific2.yml", r###"
name: specific1
"###);
@ -578,7 +684,7 @@ mod tests {
}
#[test]
fn test_specific_config_set_merge_with_parent_matches() {
fn test_user_defined_config_set_merge_with_parent_matches() {
let tmp_dir = TempDir::new().expect("unable to create temp directory");
let default_path = tmp_dir.path().join(DEFAULT_CONFIG_FILE_NAME);
fs::write(default_path, r###"
@ -589,9 +695,7 @@ mod tests {
replace: "Bob"
"###);
let specific_path = tmp_dir.path().join("specific.yaml");
let specific_path_copy = specific_path.clone();
fs::write(specific_path, r###"
let user_defined_path = create_user_config_file(tmp_dir.path(), "specific1.yml", r###"
name: specific1
matches:
@ -609,7 +713,7 @@ mod tests {
}
#[test]
fn test_specific_config_set_merge_with_parent_matches_child_priority() {
fn test_user_defined_config_set_merge_with_parent_matches_child_priority() {
let tmp_dir = TempDir::new().expect("unable to create temp directory");
let default_path = tmp_dir.path().join(DEFAULT_CONFIG_FILE_NAME);
fs::write(default_path, r###"
@ -620,9 +724,7 @@ mod tests {
replace: "Bob"
"###);
let specific_path = tmp_dir.path().join("specific.yaml");
let specific_path_copy = specific_path.clone();
fs::write(specific_path, r###"
let user_defined_path2 = create_user_config_file(tmp_dir.path(), "specific2.yml", r###"
name: specific1
matches:
@ -639,7 +741,7 @@ mod tests {
}
#[test]
fn test_specific_config_set_exclude_merge_with_parent_matches() {
fn test_user_defined_config_set_exclude_merge_with_parent_matches() {
let tmp_dir = TempDir::new().expect("unable to create temp directory");
let default_path = tmp_dir.path().join(DEFAULT_CONFIG_FILE_NAME);
fs::write(default_path, r###"
@ -650,12 +752,10 @@ mod tests {
replace: "Bob"
"###);
let specific_path = tmp_dir.path().join("specific.yaml");
let specific_path_copy = specific_path.clone();
fs::write(specific_path, r###"
let user_defined_path2 = create_user_config_file(tmp_dir.path(), "specific2.yml", r###"
name: specific1
exclude_parent_matches: true
exclude_default_matches: true
matches:
- trigger: "hello"
@ -681,12 +781,10 @@ mod tests {
replace: "Bob"
"###);
let specific_path = tmp_dir.path().join("specific.zzz");
let specific_path_copy = specific_path.clone();
fs::write(specific_path, r###"
let user_defined_path2 = create_user_config_file(tmp_dir.path(), "specific.zzz", r###"
name: specific1
exclude_parent_matches: true
exclude_default_matches: true
matches:
- trigger: "hello"
@ -696,4 +794,196 @@ mod tests {
let config_set = ConfigSet::load(tmp_dir.path()).unwrap();
assert_eq!(config_set.specific.len(), 0);
}
#[test]
fn test_config_set_no_parent_configs_works_correctly() {
let tmp_dir = create_temp_espanso_directory();
let user_defined_path = create_user_config_file(tmp_dir.path(), "specific.yml", r###"
name: specific1
"###);
let user_defined_path2 = create_user_config_file(tmp_dir.path(), "specific2.yml", r###"
name: specific2
"###);
let config_set = ConfigSet::load(tmp_dir.path()).unwrap();
assert_eq!(config_set.specific.len(), 2);
}
#[test]
fn test_config_set_default_parent_works_correctly() {
let tmp_dir = create_temp_espanso_directory_with_default_content(r###"
matches:
- trigger: hasta
replace: Hasta la vista
"###);
let user_defined_path = create_user_config_file(tmp_dir.path(), "specific.yml", r###"
parent: default
matches:
- trigger: "hello"
replace: "world"
"###);
let config_set = ConfigSet::load(tmp_dir.path()).unwrap();
assert_eq!(config_set.specific.len(), 0);
assert_eq!(config_set.default.matches.len(), 2);
assert!(config_set.default.matches.iter().any(|m| m.trigger == "hasta"));
assert!(config_set.default.matches.iter().any(|m| m.trigger == "hello"));
}
#[test]
fn test_config_set_no_parent_should_not_merge() {
let tmp_dir = create_temp_espanso_directory_with_default_content(r###"
matches:
- trigger: hasta
replace: Hasta la vista
"###);
let user_defined_path = create_user_config_file(tmp_dir.path(), "specific.yml", r###"
matches:
- trigger: "hello"
replace: "world"
"###);
let config_set = ConfigSet::load(tmp_dir.path()).unwrap();
assert_eq!(config_set.specific.len(), 1);
assert_eq!(config_set.default.matches.len(), 1);
assert!(config_set.default.matches.iter().any(|m| m.trigger == "hasta"));
assert!(!config_set.default.matches.iter().any(|m| m.trigger == "hello"));
assert!(config_set.specific[0].matches.iter().any(|m| m.trigger == "hello"));
}
#[test]
fn test_config_set_default_nested_parent_works_correctly() {
let tmp_dir = create_temp_espanso_directory_with_default_content(r###"
matches:
- trigger: hasta
replace: Hasta la vista
"###);
let user_defined_path = create_user_config_file(tmp_dir.path(), "specific.yml", r###"
name: custom1
parent: default
matches:
- trigger: "hello"
replace: "world"
"###);
let user_defined_path2 = create_user_config_file(tmp_dir.path(), "specific2.yml", r###"
parent: custom1
matches:
- trigger: "super"
replace: "mario"
"###);
let config_set = ConfigSet::load(tmp_dir.path()).unwrap();
assert_eq!(config_set.specific.len(), 0);
assert_eq!(config_set.default.matches.len(), 3);
assert!(config_set.default.matches.iter().any(|m| m.trigger == "hasta"));
assert!(config_set.default.matches.iter().any(|m| m.trigger == "hello"));
assert!(config_set.default.matches.iter().any(|m| m.trigger == "super"));
}
#[test]
fn test_config_set_parent_merge_children_priority_should_be_higher() {
let tmp_dir = create_temp_espanso_directory_with_default_content(r###"
matches:
- trigger: hasta
replace: Hasta la vista
"###);
let user_defined_path = create_user_config_file(tmp_dir.path(), "specific.yml", r###"
parent: default
matches:
- trigger: "hasta"
replace: "world"
"###);
let config_set = ConfigSet::load(tmp_dir.path()).unwrap();
assert_eq!(config_set.specific.len(), 0);
assert_eq!(config_set.default.matches.len(), 1);
assert!(config_set.default.matches.iter().any(|m| m.trigger == "hasta" && m.replace == "world"));
}
#[test]
fn test_config_set_package_configs_default_merge() {
let tmp_dir = create_temp_espanso_directory_with_default_content(r###"
matches:
- trigger: hasta
replace: Hasta la vista
"###);
let package_path = create_package_file(tmp_dir.path(), "package1", "package.yml", r###"
parent: default
matches:
- trigger: "harry"
replace: "potter"
"###);
let config_set = ConfigSet::load(tmp_dir.path()).unwrap();
assert_eq!(config_set.specific.len(), 0);
assert_eq!(config_set.default.matches.len(), 2);
assert!(config_set.default.matches.iter().any(|m| m.trigger == "hasta"));
assert!(config_set.default.matches.iter().any(|m| m.trigger == "harry"));
}
#[test]
fn test_config_set_package_configs_without_merge() {
let tmp_dir = create_temp_espanso_directory_with_default_content(r###"
matches:
- trigger: hasta
replace: Hasta la vista
"###);
let package_path = create_package_file(tmp_dir.path(), "package1", "package.yml", r###"
matches:
- trigger: "harry"
replace: "potter"
"###);
let config_set = ConfigSet::load(tmp_dir.path()).unwrap();
assert_eq!(config_set.specific.len(), 1);
assert_eq!(config_set.default.matches.len(), 1);
assert!(config_set.default.matches.iter().any(|m| m.trigger == "hasta"));
assert!(config_set.specific[0].matches.iter().any(|m| m.trigger == "harry"));
}
#[test]
fn test_config_set_package_configs_multiple_files() {
let tmp_dir = create_temp_espanso_directory_with_default_content(r###"
matches:
- trigger: hasta
replace: Hasta la vista
"###);
let package_path = create_package_file(tmp_dir.path(), "package1", "package.yml", r###"
name: package1
matches:
- trigger: "harry"
replace: "potter"
"###);
let package_path2 = create_package_file(tmp_dir.path(), "package1", "addon.yml", r###"
parent: package1
matches:
- trigger: "ron"
replace: "weasley"
"###);
let config_set = ConfigSet::load(tmp_dir.path()).unwrap();
assert_eq!(config_set.specific.len(), 1);
assert_eq!(config_set.default.matches.len(), 1);
assert!(config_set.default.matches.iter().any(|m| m.trigger == "hasta"));
assert!(config_set.specific[0].matches.iter().any(|m| m.trigger == "harry"));
assert!(config_set.specific[0].matches.iter().any(|m| m.trigger == "ron"));
}
}

View File

@ -204,13 +204,12 @@ impl <'a, S: SystemManager> super::ConfigManager<'a> for RuntimeConfigManager<'a
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::{NamedTempFile, TempDir};
use crate::config::{DEFAULT_CONFIG_FILE_NAME, DEFAULT_CONFIG_FILE_CONTENT};
use std::fs;
use std::path::PathBuf;
use crate::config::ConfigManager;
use crate::config::tests::{create_temp_espanso_directory, create_temp_file_in_dir};
use crate::config::tests::{create_temp_espanso_directory, create_temp_file_in_dir, create_user_config_file};
struct DummySystemManager {
title: RefCell<String>,
@ -252,18 +251,18 @@ mod tests {
fn test_runtime_constructor_regex_load_correctly() {
let tmp_dir = create_temp_espanso_directory();
let specific_path = create_temp_file_in_dir(&tmp_dir, "specific.yaml", r###"
let specific_path = create_user_config_file(&tmp_dir.path(), "specific.yml", r###"
name: myname1
filter_exec: "Title"
"###);
let specific_path2 = create_temp_file_in_dir(&tmp_dir, "specific2.yaml", r###"
let specific_path2 = create_user_config_file(&tmp_dir.path(), "specific2.yml", r###"
name: myname2
filter_title: "Yeah"
filter_class: "Car"
"###);
let specific_path3 = create_temp_file_in_dir(&tmp_dir, "specific3.yaml", r###"
let specific_path3 = create_user_config_file(&tmp_dir.path(), "specific3.yml", r###"
name: myname3
filter_title: "Nice"
"###);
@ -303,18 +302,18 @@ mod tests {
fn test_runtime_constructor_malformed_regexes_are_ignored() {
let tmp_dir = create_temp_espanso_directory();
let specific_path = create_temp_file_in_dir(&tmp_dir, "specific.yaml", r###"
let specific_path = create_user_config_file(&tmp_dir.path(), "specific.yml", r###"
name: myname1
filter_exec: "[`-_]"
"###);
let specific_path2 = create_temp_file_in_dir(&tmp_dir, "specific2.yaml", r###"
let specific_path2 = create_user_config_file(&tmp_dir.path(), "specific2.yml", r###"
name: myname2
filter_title: "[`-_]"
filter_class: "Car"
"###);
let specific_path3 = create_temp_file_in_dir(&tmp_dir, "specific3.yaml", r###"
let specific_path3 = create_user_config_file(&tmp_dir.path(), "specific3.yml", r###"
name: myname3
filter_title: "Nice"
"###);
@ -354,7 +353,7 @@ mod tests {
fn test_runtime_calculate_active_config_specific_title_match() {
let tmp_dir = create_temp_espanso_directory();
let specific_path = create_temp_file_in_dir(&tmp_dir, "specific.yaml", r###"
let specific_path = create_user_config_file(&tmp_dir.path(), "specific.yml", r###"
name: chrome
filter_title: "Chrome"
"###);
@ -372,7 +371,7 @@ mod tests {
fn test_runtime_calculate_active_config_specific_class_match() {
let tmp_dir = create_temp_espanso_directory();
let specific_path = create_temp_file_in_dir(&tmp_dir, "specific.yaml", r###"
let specific_path = create_user_config_file(&tmp_dir.path(), "specific.yml", r###"
name: chrome
filter_class: "Chrome"
"###);
@ -390,7 +389,7 @@ mod tests {
fn test_runtime_calculate_active_config_specific_exec_match() {
let tmp_dir = create_temp_espanso_directory();
let specific_path = create_temp_file_in_dir(&tmp_dir, "specific.yaml", r###"
let specific_path = create_user_config_file(&tmp_dir.path(), "specific.yml", r###"
name: chrome
filter_exec: "chrome.exe"
"###);
@ -408,7 +407,7 @@ mod tests {
fn test_runtime_calculate_active_config_specific_multi_filter_match() {
let tmp_dir = create_temp_espanso_directory();
let specific_path = create_temp_file_in_dir(&tmp_dir, "specific.yaml", r###"
let specific_path = create_user_config_file(&tmp_dir.path(), "specific.yml", r###"
name: chrome
filter_class: Browser
filter_exec: "firefox.exe"
@ -428,7 +427,7 @@ mod tests {
fn test_runtime_calculate_active_config_no_match() {
let tmp_dir = create_temp_espanso_directory();
let specific_path = create_temp_file_in_dir(&tmp_dir, "specific.yaml", r###"
let specific_path = create_user_config_file(&tmp_dir.path(), "specific.yml", r###"
name: firefox
filter_title: "Firefox"
"###);
@ -447,7 +446,7 @@ mod tests {
fn test_runtime_active_config_cache() {
let tmp_dir = create_temp_espanso_directory();
let specific_path = create_temp_file_in_dir(&tmp_dir, "specific.yaml", r###"
let specific_path = create_user_config_file(&tmp_dir.path(), "specific.yml", r###"
name: firefox
filter_title: "Firefox"
"###);

View File

@ -43,27 +43,36 @@ use crate::system::SystemManager;
use crate::ui::UIManager;
use crate::protocol::*;
use std::io::{BufReader, BufRead};
use crate::package::default::DefaultPackageManager;
use crate::package::{PackageManager, InstallResult, UpdateResult, RemoveResult};
mod ui;
mod event;
mod check;
mod utils;
mod bridge;
mod engine;
mod config;
mod system;
mod sysdaemon;
mod context;
mod matcher;
mod package;
mod keyboard;
mod protocol;
mod clipboard;
mod extension;
mod sysdaemon;
const VERSION: &'static str = env!("CARGO_PKG_VERSION");
const LOG_FILE: &str = "espanso.log";
fn main() {
let matches = App::new("espanso")
let install_subcommand = SubCommand::with_name("install")
.about("Install a package. Equivalent to 'espanso package install'")
.arg(Arg::with_name("package_name")
.help("Package name"));
let mut clap_instance = App::new("espanso")
.version(VERSION)
.author("Federico Terzi")
.about("Cross-platform Text Expander written in Rust")
@ -71,7 +80,7 @@ fn main() {
.short("c")
.long("config")
.value_name("FILE")
.help("Sets a custom config directory. If not specified, reads the default $HOME/.espanso/default.yaml file, creating it if not present.")
.help("Sets a custom config directory. If not specified, reads the default $HOME/.espanso/default.yml file, creating it if not present.")
.takes_value(true))
.arg(Arg::with_name("v")
.short("v")
@ -108,7 +117,26 @@ fn main() {
.about("Restart the espanso daemon."))
.subcommand(SubCommand::with_name("status")
.about("Check if the espanso daemon is running or not."))
.get_matches();
// Package manager
.subcommand(SubCommand::with_name("package")
.about("Espanso package manager commands")
.subcommand(install_subcommand.clone())
.subcommand(SubCommand::with_name("list")
.about("List all installed packages")
.arg(Arg::with_name("full")
.help("Print all package info")
.long("full")))
.subcommand(SubCommand::with_name("remove")
.about("Remove an installed package")
.arg(Arg::with_name("package_name")
.help("Package name")))
.subcommand(SubCommand::with_name("refresh")
.about("Update espanso package index"))
)
.subcommand(install_subcommand);
let matches = clap_instance.clone().get_matches();
let log_level = matches.occurrences_of("v") as i32;
@ -190,8 +218,32 @@ fn main() {
return;
}
// Defaults to start subcommand
start_main(config_set);
if let Some(matches) = matches.subcommand_matches("install") {
install_main(config_set, matches);
return;
}
if let Some(matches) = matches.subcommand_matches("package") {
if let Some(matches) = matches.subcommand_matches("install") {
install_main(config_set, matches);
return;
}
if let Some(matches) = matches.subcommand_matches("remove") {
remove_package_main(config_set, matches);
return;
}
if let Some(matches) = matches.subcommand_matches("list") {
list_package_main(config_set, matches);
return;
}
if let Some(_) = matches.subcommand_matches("refresh") {
update_index_main(config_set);
return;
}
}
// Defaults help print
clap_instance.print_long_help().expect("Unable to print help");
}
/// Daemon subcommand, start the event loop and spawn a background thread worker
@ -547,6 +599,142 @@ fn unregister_main(config_set: ConfigSet) {
sysdaemon::unregister(config_set);
}
fn install_main(_config_set: ConfigSet, matches: &ArgMatches) {
let package_name = matches.value_of("package_name").unwrap_or_else(|| {
eprintln!("Missing package name!");
exit(1);
});
let mut package_manager = DefaultPackageManager::new_default();
if package_manager.is_index_outdated() {
println!("Updating package index...");
let res = package_manager.update_index(false);
match res {
Ok(update_result) => {
match update_result {
UpdateResult::NotOutdated => {
eprintln!("Index was already up to date");
},
UpdateResult::Updated => {
println!("Index updated!");
},
}
},
Err(e) => {
eprintln!("{}", e);
exit(2);
},
}
}else{
println!("Using cached package index, run 'espanso package refresh' to update it.")
}
let res = package_manager.install_package(package_name);
match res {
Ok(install_result) => {
match install_result {
InstallResult::NotFoundInIndex => {
eprintln!("Package not found");
},
InstallResult::NotFoundInRepo => {
eprintln!("Package not found in repository, are you sure the folder exist in the repo?");
},
InstallResult::UnableToParsePackageInfo => {
eprintln!("Unable to parse Package info from README.md");
},
InstallResult::MissingPackageVersion => {
eprintln!("Missing package version");
},
InstallResult::AlreadyInstalled => {
eprintln!("{} already installed!", package_name);
},
InstallResult::Installed => {
println!("{} successfully installed!", package_name);
println!();
println!("You need to restart espanso for changes to take effect, using:");
println!(" espanso restart");
},
}
},
Err(e) => {
eprintln!("{}", e);
},
}
}
fn remove_package_main(_config_set: ConfigSet, matches: &ArgMatches) {
let package_name = matches.value_of("package_name").unwrap_or_else(|| {
eprintln!("Missing package name!");
exit(1);
});
let package_manager = DefaultPackageManager::new_default();
let res = package_manager.remove_package(package_name);
match res {
Ok(remove_result) => {
match remove_result {
RemoveResult::NotFound => {
eprintln!("{} package was not installed.", package_name);
},
RemoveResult::Removed => {
println!("{} successfully removed!", package_name);
println!();
println!("You need to restart espanso for changes to take effect, using:");
println!(" espanso restart");
},
}
},
Err(e) => {
eprintln!("{}", e);
},
}
}
fn update_index_main(_config_set: ConfigSet) {
let mut package_manager = DefaultPackageManager::new_default();
let res = package_manager.update_index(true);
match res {
Ok(update_result) => {
match update_result {
UpdateResult::NotOutdated => {
eprintln!("Index was already up to date");
},
UpdateResult::Updated => {
println!("Index updated!");
},
}
},
Err(e) => {
eprintln!("{}", e);
exit(2);
},
}
}
fn list_package_main(_config_set: ConfigSet, matches: &ArgMatches) {
let package_manager = DefaultPackageManager::new_default();
let list = package_manager.list_local_packages();
if matches.is_present("full") {
for package in list.iter() {
println!("{:?}", package);
}
}else{
for package in list.iter() {
println!("{} - {}", package.name, package.version);
}
}
}
fn acquire_lock() -> Option<File> {
let espanso_dir = context::get_data_dir();
let lock_file_path = espanso_dir.join("espanso.lock");

597
src/package/default.rs Normal file
View File

@ -0,0 +1,597 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
use std::path::{PathBuf, Path};
use crate::package::{PackageIndex, UpdateResult, Package, InstallResult, RemoveResult};
use std::error::Error;
use std::fs::{File, create_dir};
use std::io::{BufReader, BufRead};
use std::time::{SystemTime, UNIX_EPOCH};
use crate::package::UpdateResult::{NotOutdated, Updated};
use crate::package::InstallResult::{NotFoundInIndex, AlreadyInstalled};
use std::fs;
use tempfile::TempDir;
use git2::Repository;
use regex::Regex;
use crate::package::RemoveResult::Removed;
use std::collections::HashMap;
const DEFAULT_PACKAGE_INDEX_FILE : &str = "package_index.json";
pub struct DefaultPackageManager {
package_dir: PathBuf,
data_dir: PathBuf,
local_index: Option<PackageIndex>,
}
impl DefaultPackageManager {
pub fn new(package_dir: PathBuf, data_dir: PathBuf) -> DefaultPackageManager {
let local_index = Self::load_local_index(&data_dir);
DefaultPackageManager{
package_dir,
data_dir,
local_index
}
}
pub fn new_default() -> DefaultPackageManager {
DefaultPackageManager::new(
crate::config::ConfigSet::get_default_packages_dir(),
crate::context::get_data_dir()
)
}
fn get_package_index_path(data_dir: &Path) -> PathBuf {
data_dir.join(DEFAULT_PACKAGE_INDEX_FILE)
}
fn load_local_index(data_dir: &Path) -> Option<super::PackageIndex> {
let local_index_file = File::open(Self::get_package_index_path(data_dir));
if let Ok(local_index_file) = local_index_file {
let reader = BufReader::new(local_index_file);
let local_index = serde_json::from_reader(reader);
if let Ok(local_index) = local_index {
return local_index
}
}
None
}
fn request_index() -> Result<super::PackageIndex, Box<dyn Error>> {
let client = reqwest::Client::new();
let request = client.get("https://hub.espanso.org/json/")
.header("User-Agent", format!("espanso/{}", crate::VERSION));
let mut res = request.send()?;
let body = res.text()?;
let index : PackageIndex = serde_json::from_str(&body)?;
Ok(index)
}
fn clone_repo_to_temp(repo_url: &str) -> Result<TempDir, Box<dyn Error>> {
let temp_dir = TempDir::new()?;
let _repo = Repository::clone(repo_url, temp_dir.path())?;
Ok(temp_dir)
}
fn parse_package_from_readme(readme_path: &Path) -> Option<Package> {
lazy_static! {
static ref FIELD_REGEX: Regex = Regex::new(r###"^\s*(.*?)\s*:\s*"?(.*?)"?$"###).unwrap();
}
// Read readme line by line
let file = File::open(readme_path);
if let Ok(file) = file {
let reader = BufReader::new(file);
let mut fields :HashMap<String, String> = HashMap::new();
let mut started = false;
for (_index, line) in reader.lines().enumerate() {
let line = line.unwrap();
if line.contains("---") {
if started {
break
}else{
started = true;
}
}else{
if started {
let caps = FIELD_REGEX.captures(&line);
if let Some(caps) = caps {
let property = caps.get(1);
let value = caps.get(2);
if property.is_some() && value.is_some() {
fields.insert(property.unwrap().as_str().to_owned(),
value.unwrap().as_str().to_owned());
}
}
}
}
}
if !fields.contains_key("package_name") ||
!fields.contains_key("package_title") ||
!fields.contains_key("package_version") ||
!fields.contains_key("package_repo") ||
!fields.contains_key("package_desc") ||
!fields.contains_key("package_author") {
return None
}
let package = Package {
name: fields.get("package_name").unwrap().clone(),
title: fields.get("package_title").unwrap().clone(),
version: fields.get("package_version").unwrap().clone(),
repo: fields.get("package_repo").unwrap().clone(),
desc: fields.get("package_desc").unwrap().clone(),
author: fields.get("package_author").unwrap().clone()
};
Some(package)
}else{
None
}
}
fn local_index_timestamp(&self) -> u64 {
if let Some(local_index) = &self.local_index {
return local_index.last_update
}
return 0;
}
fn list_local_packages_names(&self) -> Vec<String> {
let dir = fs::read_dir(&self.package_dir);
let mut output = Vec::new();
if let Ok(dir) = dir {
for entry in dir {
if let Ok(entry) = entry {
let path = entry.path();
if path.is_dir() {
let name = path.file_name();
if let Some(name) = name {
output.push(name.to_str().unwrap().to_owned())
}
}
}
}
}
output
}
fn cache_local_index(&self) {
if let Some(local_index) = &self.local_index {
let serialized = serde_json::to_string(local_index).expect("Unable to serialize local index");
let local_index_file = self.data_dir.join(DEFAULT_PACKAGE_INDEX_FILE);
std::fs::write(local_index_file, serialized).expect("Unable to cache local index");
}
}
}
impl super::PackageManager for DefaultPackageManager {
fn is_index_outdated(&self) -> bool {
let current_time = SystemTime::now().duration_since(UNIX_EPOCH).expect("Time went backwards");
let current_timestamp = current_time.as_secs();
let local_index_timestamp = self.local_index_timestamp();
// Local index is outdated if older than a day
local_index_timestamp + 60*60*24 < current_timestamp
}
fn update_index(&mut self, force: bool) -> Result<UpdateResult, Box<dyn Error>> {
if force || self.is_index_outdated() {
let updated_index = DefaultPackageManager::request_index()?;
self.local_index = Some(updated_index);
// Save the index to file
self.cache_local_index();
Ok(Updated)
}else{
Ok(NotOutdated)
}
}
fn get_package(&self, name: &str) -> Option<Package> {
if let Some(local_index) = &self.local_index {
let result = local_index.packages.iter().find(|package| {
package.name == name
});
if let Some(package) = result {
return Some(package.clone())
}
}
None
}
fn install_package(&self, name: &str) -> Result<InstallResult, Box<dyn Error>> {
let package = self.get_package(name);
match package {
Some(package) => {
self.install_package_from_repo(name, &package.repo)
},
None => {
Ok(NotFoundInIndex)
},
}
}
fn install_package_from_repo(&self, name: &str, repo_url: &str) -> Result<InstallResult, Box<dyn Error>> {
// Check if package is already installed
let packages = self.list_local_packages_names();
if packages.iter().any(|p| p == name) { // Package already installed
return Ok(AlreadyInstalled);
}
let temp_dir = Self::clone_repo_to_temp(repo_url)?;
let temp_package_dir = temp_dir.path().join(name);
if !temp_package_dir.exists() {
return Ok(InstallResult::NotFoundInRepo);
}
let readme_path = temp_package_dir.join("README.md");
let package = Self::parse_package_from_readme(&readme_path);
if !package.is_some() {
return Ok(InstallResult::UnableToParsePackageInfo); // TODO: test
}
let package = package.unwrap();
let source_dir = temp_package_dir.join(package.version);
if !source_dir.exists() {
return Ok(InstallResult::MissingPackageVersion); // TODO: test
}
let target_dir = &self.package_dir.join(name);
create_dir(&target_dir)?;
crate::utils::copy_dir(&source_dir, target_dir)?;
let readme_dest = target_dir.join("README.md");
std::fs::copy(readme_path, readme_dest)?;
Ok(InstallResult::Installed)
}
fn remove_package(&self, name: &str) -> Result<RemoveResult, Box<dyn Error>> {
let package_dir = self.package_dir.join(name);
if !package_dir.exists() {
return Ok(RemoveResult::NotFound);
}
std::fs::remove_dir_all(package_dir)?;
Ok(Removed)
}
fn list_local_packages(&self) -> Vec<Package> {
let mut output = Vec::new();
let package_names = self.list_local_packages_names();
for name in package_names.iter() {
let package_dir = &self.package_dir.join(name);
let readme_file = package_dir.join("README.md");
let package = Self::parse_package_from_readme(&readme_file);
if let Some(package) = package {
output.push(package);
}
}
output
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::{TempDir, NamedTempFile};
use std::path::Path;
use crate::package::PackageManager;
use std::fs::{create_dir, create_dir_all};
use crate::package::InstallResult::{Installed, NotFoundInRepo};
use std::io::Write;
const OUTDATED_INDEX_CONTENT : &str = include_str!("../res/test/outdated_index.json");
const INDEX_CONTENT_WITHOUT_UPDATE: &str = include_str!("../res/test/index_without_update.json");
const GET_PACKAGE_INDEX: &str = include_str!("../res/test/get_package_index.json");
const INSTALL_PACKAGE_INDEX: &str = include_str!("../res/test/install_package_index.json");
struct TempPackageManager {
package_dir: TempDir,
data_dir: TempDir,
package_manager: DefaultPackageManager,
}
fn create_temp_package_manager<F>(setup: F) -> TempPackageManager where F: Fn(&Path, &Path) -> (){
let package_dir = TempDir::new().expect("unable to create temp directory");
let data_dir = TempDir::new().expect("unable to create temp directory");
setup(package_dir.path(), data_dir.path());
let package_manager = DefaultPackageManager::new(
package_dir.path().clone().to_path_buf(),
data_dir.path().clone().to_path_buf()
);
TempPackageManager {
package_dir,
data_dir,
package_manager
}
}
#[test]
fn test_download_index() {
let temp = create_temp_package_manager(|_, _| {});
let index = DefaultPackageManager::request_index();
assert!(index.is_ok());
assert!(index.unwrap().packages.len() > 0);
}
#[test]
fn test_outdated_index() {
let temp = create_temp_package_manager(|_, data_dir| {
let index_file = data_dir.join(DEFAULT_PACKAGE_INDEX_FILE);
std::fs::write(index_file, OUTDATED_INDEX_CONTENT);
});
assert!(temp.package_manager.is_index_outdated());
}
#[test]
fn test_up_to_date_index_should_not_be_updated() {
let mut temp = create_temp_package_manager(|_, data_dir| {
let index_file = data_dir.join(DEFAULT_PACKAGE_INDEX_FILE);
let current_time = SystemTime::now().duration_since(UNIX_EPOCH).expect("Time went backwards");
let current_timestamp = current_time.as_secs();
let new_contents = INDEX_CONTENT_WITHOUT_UPDATE.replace("XXXX", &format!("{}", current_timestamp));
std::fs::write(index_file, new_contents);
});
assert_eq!(temp.package_manager.update_index(false).unwrap(), UpdateResult::NotOutdated);
}
#[test]
fn test_up_to_date_index_with_force_should_be_updated() {
let mut temp = create_temp_package_manager(|_, data_dir| {
let index_file = data_dir.join(DEFAULT_PACKAGE_INDEX_FILE);
let current_time = SystemTime::now().duration_since(UNIX_EPOCH).expect("Time went backwards");
let current_timestamp = current_time.as_secs();
let new_contents = INDEX_CONTENT_WITHOUT_UPDATE.replace("XXXX", &format!("{}", current_timestamp));
std::fs::write(index_file, new_contents);
});
assert_eq!(temp.package_manager.update_index(true).unwrap(), UpdateResult::Updated);
}
#[test]
fn test_outdated_index_should_be_updated() {
let mut temp = create_temp_package_manager(|_, data_dir| {
let index_file = data_dir.join(DEFAULT_PACKAGE_INDEX_FILE);
std::fs::write(index_file, OUTDATED_INDEX_CONTENT);
});
assert_eq!(temp.package_manager.update_index(false).unwrap(), UpdateResult::Updated);
}
#[test]
fn test_update_index_should_create_file() {
let mut temp = create_temp_package_manager(|_, _| {});
assert_eq!(temp.package_manager.update_index(false).unwrap(), UpdateResult::Updated);
assert!(temp.data_dir.path().join(DEFAULT_PACKAGE_INDEX_FILE).exists())
}
#[test]
fn test_get_package_should_be_found() {
let mut temp = create_temp_package_manager(|_, data_dir| {
let index_file = data_dir.join(DEFAULT_PACKAGE_INDEX_FILE);
std::fs::write(index_file, GET_PACKAGE_INDEX);
});
assert_eq!(temp.package_manager.get_package("italian-accents").unwrap().title, "Italian Accents");
}
#[test]
fn test_get_package_should_not_be_found() {
let mut temp = create_temp_package_manager(|_, data_dir| {
let index_file = data_dir.join(DEFAULT_PACKAGE_INDEX_FILE);
std::fs::write(index_file, GET_PACKAGE_INDEX);
});
assert!(temp.package_manager.get_package("not-existing").is_none());
}
#[test]
fn test_list_local_packages_names() {
let mut temp = create_temp_package_manager(|package_dir, _| {
create_dir(package_dir.join("package-1"));
create_dir(package_dir.join("package2"));
std::fs::write(package_dir.join("dummyfile.txt"), "test");
});
let packages = temp.package_manager.list_local_packages_names();
assert_eq!(packages.len(), 2);
assert!(packages.iter().any(|p| p == "package-1"));
assert!(packages.iter().any(|p| p == "package2"));
}
#[test]
fn test_install_package_not_found() {
let mut temp = create_temp_package_manager(|package_dir, data_dir| {
let index_file = data_dir.join(DEFAULT_PACKAGE_INDEX_FILE);
std::fs::write(index_file, INSTALL_PACKAGE_INDEX);
});
assert_eq!(temp.package_manager.install_package("doesnotexist").unwrap(), NotFoundInIndex);
}
#[test]
fn test_install_package_already_installed() {
let mut temp = create_temp_package_manager(|package_dir, data_dir| {
create_dir(package_dir.join("italian-accents"));
let index_file = data_dir.join(DEFAULT_PACKAGE_INDEX_FILE);
std::fs::write(index_file, INSTALL_PACKAGE_INDEX);
});
assert_eq!(temp.package_manager.install_package("italian-accents").unwrap(), AlreadyInstalled);
}
#[test]
fn test_clone_temp_repository() {
let cloned_dir = DefaultPackageManager::clone_repo_to_temp("https://github.com/federico-terzi/espanso-hub-core").unwrap();
assert!(cloned_dir.path().join("LICENSE").exists());
}
#[test]
fn test_install_package() {
let mut temp = create_temp_package_manager(|_, data_dir| {
let index_file = data_dir.join(DEFAULT_PACKAGE_INDEX_FILE);
std::fs::write(index_file, INSTALL_PACKAGE_INDEX);
});
assert_eq!(temp.package_manager.install_package("dummy-package").unwrap(), Installed);
assert!(temp.package_dir.path().join("dummy-package").exists());
assert!(temp.package_dir.path().join("dummy-package/README.md").exists());
assert!(temp.package_dir.path().join("dummy-package/package.yml").exists());
}
#[test]
fn test_install_package_does_not_exist_in_repo() {
let mut temp = create_temp_package_manager(|_, data_dir| {
let index_file = data_dir.join(DEFAULT_PACKAGE_INDEX_FILE);
std::fs::write(index_file, INSTALL_PACKAGE_INDEX);
});
assert_eq!(temp.package_manager.install_package("not-existing").unwrap(), NotFoundInRepo);
}
#[test]
fn test_list_local_packages() {
let mut temp = create_temp_package_manager(|_, data_dir| {
let index_file = data_dir.join(DEFAULT_PACKAGE_INDEX_FILE);
std::fs::write(index_file, INSTALL_PACKAGE_INDEX);
});
assert_eq!(temp.package_manager.install_package("dummy-package").unwrap(), Installed);
assert!(temp.package_dir.path().join("dummy-package").exists());
assert!(temp.package_dir.path().join("dummy-package/README.md").exists());
assert!(temp.package_dir.path().join("dummy-package/package.yml").exists());
let list = temp.package_manager.list_local_packages();
assert_eq!(list.len(), 1);
assert_eq!(list[0].name, "dummy-package");
}
#[test]
fn test_remove_package() {
let mut temp = create_temp_package_manager(|package_dir, _| {
let dummy_package_dir = package_dir.join("dummy-package");
create_dir_all(&dummy_package_dir);
std::fs::write(dummy_package_dir.join("README.md"), "readme");
std::fs::write(dummy_package_dir.join("package.yml"), "name: package");
});
assert!(temp.package_dir.path().join("dummy-package").exists());
assert!(temp.package_dir.path().join("dummy-package/README.md").exists());
assert!(temp.package_dir.path().join("dummy-package/package.yml").exists());
assert_eq!(temp.package_manager.remove_package("dummy-package").unwrap(), RemoveResult::Removed);
assert!(!temp.package_dir.path().join("dummy-package").exists());
assert!(!temp.package_dir.path().join("dummy-package/README.md").exists());
assert!(!temp.package_dir.path().join("dummy-package/package.yml").exists());
}
#[test]
fn test_remove_package_not_found() {
let mut temp = create_temp_package_manager(|_, _| {});
assert_eq!(temp.package_manager.remove_package("not-existing").unwrap(), RemoveResult::NotFound);
}
#[test]
fn test_parse_package_from_readme() {
let file = NamedTempFile::new().unwrap();
fs::write(file.path(), r###"
---
package_name: "italian-accents"
package_title: "Italian Accents"
package_desc: "Include Italian accents substitutions to espanso."
package_version: "0.1.0"
package_author: "Federico Terzi"
package_repo: "https://github.com/federico-terzi/espanso-hub-core"
---
"###);
let package = DefaultPackageManager::parse_package_from_readme(file.path()).unwrap();
let target_package = Package {
name: "italian-accents".to_string(),
title: "Italian Accents".to_string(),
version: "0.1.0".to_string(),
repo: "https://github.com/federico-terzi/espanso-hub-core".to_string(),
desc: "Include Italian accents substitutions to espanso.".to_string(),
author: "Federico Terzi".to_string()
};
assert_eq!(package, target_package);
}
#[test]
fn test_parse_package_from_readme_with_bad_metadata() {
let file = NamedTempFile::new().unwrap();
fs::write(file.path(), r###"
---
package_name: italian-accents
package_title: "Italian Accents"
package_desc: "Include Italian accents substitutions to espanso."
package_version:"0.1.0"
package_author:Federico Terzi
package_repo: "https://github.com/federico-terzi/espanso-hub-core"
---
Readme text
"###);
let package = DefaultPackageManager::parse_package_from_readme(file.path()).unwrap();
let target_package = Package {
name: "italian-accents".to_string(),
title: "Italian Accents".to_string(),
version: "0.1.0".to_string(),
repo: "https://github.com/federico-terzi/espanso-hub-core".to_string(),
desc: "Include Italian accents substitutions to espanso.".to_string(),
author: "Federico Terzi".to_string()
};
assert_eq!(package, target_package);
}
}

77
src/package/mod.rs Normal file
View File

@ -0,0 +1,77 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
pub(crate) mod default;
use serde::{Serialize, Deserialize};
use std::error::Error;
pub trait PackageManager {
fn is_index_outdated(&self) -> bool;
fn update_index(&mut self, force: bool) -> Result<UpdateResult, Box<dyn Error>>;
fn get_package(&self, name: &str) -> Option<Package>;
fn install_package(&self, name: &str) -> Result<InstallResult, Box<dyn Error>>;
fn install_package_from_repo(&self, name: &str, repo_url: &str) -> Result<InstallResult, Box<dyn Error>>;
fn remove_package(&self, name: &str) -> Result<RemoveResult, Box<dyn Error>>;
fn list_local_packages(&self) -> Vec<Package>;
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct Package {
pub name: String,
pub title: String,
pub version: String,
pub repo: String,
pub desc: String,
pub author: String
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct PackageIndex {
#[serde(rename = "lastUpdate")]
pub last_update: u64,
pub packages: Vec<Package>
}
#[derive(Clone, Debug, PartialEq)]
pub enum UpdateResult {
NotOutdated,
Updated,
}
#[derive(Clone, Debug, PartialEq)]
pub enum InstallResult {
NotFoundInIndex,
NotFoundInRepo,
UnableToParsePackageInfo,
MissingPackageVersion,
AlreadyInstalled,
Installed
}
#[derive(Clone, Debug, PartialEq)]
pub enum RemoveResult {
NotFound,
Removed
}

View File

@ -2,7 +2,7 @@
# This is the default configuration file, change it as you like it
# You can refer to the official documentation:
# https://github.com/federico-terzi/espanso
# https://espanso.org/docs/
# Matches are the substitution rules, when you type the "trigger" string
# it gets replaced by the "replace" string.
@ -27,34 +27,4 @@ matches:
- name: output
type: shell
params:
cmd: "echo Hello from you shell"
# Emojis
- trigger: ":lol"
replace: "😂"
- trigger: ":llol"
replace: "😂😂😂😂"
- trigger: ":sad"
replace: "☹"
- trigger: ":ssad"
replace: "☹☹☹☹"
# Accented letters
- trigger: "e''"
replace: "è"
- trigger: "e//"
replace: "é"
- trigger: "a''"
replace: "à"
- trigger: "i''"
replace: "ì"
- trigger: "o''"
replace: "ò"
- trigger: "u''"
replace: "ù"
# Capital accented letters
- trigger: "E''"
replace: "È"
- trigger: "E//"
replace: "É"
cmd: "echo Hello from your shell"

View File

@ -0,0 +1,27 @@
{
"lastUpdate": 1565437389,
"packages": [
{
"name": "basic-emojis",
"title": "Basic Emojis",
"version": "0.1.0",
"repo": "https://github.com/federico-terzi/espanso-hub-core",
"desc": "A package to include some basic emojis in espanso.",
"author": "Federico Terzi"
},
{
"name": "italian-accents",
"title": "Italian Accents",
"version": "0.1.0",
"repo": "https://github.com/federico-terzi/espanso-hub-core",
"desc": "Include Italian accents substitutions to espanso.",
"author": "Federico Terzi"
}
]}

View File

@ -0,0 +1,27 @@
{
"lastUpdate": XXXX,
"packages": [
{
"name": "basic-emojis",
"title": "Basic Emojis",
"version": "0.1.0",
"repo": "https://github.com/federico-terzi/espanso-hub-core",
"desc": "A package to include some basic emojis in espanso.",
"author": "Federico Terzi"
},
{
"name": "italian-accents",
"title": "Italian Accents",
"version": "0.1.0",
"repo": "https://github.com/federico-terzi/espanso-hub-core",
"desc": "Include Italian accents substitutions to espanso.",
"author": "Federico Terzi"
}
]}

View File

@ -0,0 +1,35 @@
{
"lastUpdate": 1565437389,
"packages": [
{
"name": "dummy-package",
"title": "Dummy Package",
"version": "0.1.0",
"repo": "https://github.com/federico-terzi/espanso-hub-core",
"desc": "Dummy package",
"author": "Federico Terzi"
},
{
"name": "italian-accents",
"title": "Italian Accents",
"version": "0.1.0",
"repo": "https://github.com/federico-terzi/espanso-hub-core",
"desc": "Include Italian accents substitutions to espanso.",
"author": "Federico Terzi"
},
{
"name": "not-existing",
"title": "Not Existing",
"version": "0.1.0",
"repo": "https://github.com/federico-terzi/espanso-hub-core",
"desc": "Package that does not exist in the repo",
"author": "Federico Terzi"
}
]}

View File

@ -0,0 +1,27 @@
{
"lastUpdate": 1565437389,
"packages": [
{
"name": "basic-emojis",
"title": "Basic Emojis",
"version": "0.1.0",
"repo": "https://github.com/federico-terzi/espanso-hub-core",
"desc": "A package to include some basic emojis in espanso.",
"author": "Federico Terzi"
},
{
"name": "italian-accents",
"title": "Italian Accents",
"version": "0.1.0",
"repo": "https://github.com/federico-terzi/espanso-hub-core",
"desc": "Include Italian accents substitutions to espanso.",
"author": "Federico Terzi"
}
]}

94
src/utils.rs Normal file
View File

@ -0,0 +1,94 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
use std::path::Path;
use std::error::Error;
use std::fs::create_dir;
pub fn copy_dir(source_dir: &Path, dest_dir: &Path) -> Result<(), Box<dyn Error>> {
for entry in std::fs::read_dir(source_dir)? {
let entry = entry?;
let entry = entry.path();
if entry.is_dir() {
let name = entry.file_name().expect("Error obtaining the filename");
let target_dir = dest_dir.join(name);
create_dir(&target_dir)?;
copy_dir(&entry, &target_dir)?;
}else if entry.is_file() {
let target_entry = dest_dir.join(entry.file_name().expect("Error obtaining the filename"));
std::fs::copy(entry, target_entry)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
use std::fs::create_dir;
#[test]
fn test_copy_dir_into() {
let source_tmp_dir = TempDir::new().expect("Error creating temp directory");
let dest_tmp_dir = TempDir::new().expect("Error creating temp directory");
let source_dir = source_tmp_dir.path().join("source");
create_dir(&source_dir);
std::fs::write(source_dir.join("file1.txt"), "file1");
std::fs::write(source_dir.join("file2.txt"), "file2");
let target_dir = dest_tmp_dir.path().join("source");
create_dir(&target_dir);
copy_dir(&source_dir, &target_dir);
assert!(dest_tmp_dir.path().join("source").exists());
assert!(dest_tmp_dir.path().join("source/file1.txt").exists());
assert!(dest_tmp_dir.path().join("source/file2.txt").exists());
}
#[test]
fn test_copy_dir_into_recursive() {
let source_tmp_dir = TempDir::new().expect("Error creating temp directory");
let dest_tmp_dir = TempDir::new().expect("Error creating temp directory");
let source_dir = source_tmp_dir.path().join("source");
create_dir(&source_dir);
std::fs::write(source_dir.join("file1.txt"), "file1");
std::fs::write(source_dir.join("file2.txt"), "file2");
let nested_dir = source_dir.join("nested");
create_dir(&nested_dir);
std::fs::write(nested_dir.join("nestedfile.txt"), "nestedfile1");
let target_dir = dest_tmp_dir.path().join("source");
create_dir(&target_dir);
copy_dir(&source_dir, &target_dir);
assert!(dest_tmp_dir.path().join("source").exists());
assert!(dest_tmp_dir.path().join("source/file1.txt").exists());
assert!(dest_tmp_dir.path().join("source/file2.txt").exists());
assert!(dest_tmp_dir.path().join("source/nested").exists());
assert!(dest_tmp_dir.path().join("source/nested/nestedfile.txt").exists());
}
}