awatcher/watchers/src/config/file_config.rs
2024-10-10 23:07:35 -04:00

269 lines
7.9 KiB
Rust

use anyhow::{anyhow, Context};
use chrono::TimeDelta;
use serde::Deserialize;
use serde_default::DefaultFromSerde;
use std::{fs, io::ErrorKind, path::PathBuf};
use crate::config::defaults;
use super::filters::Filter;
pub fn default_config() -> String {
format!(
r#"# The commented values are the defaults on the file creation
[server]
# port = {}
# host = "{}"
[awatcher]
# idle-timeout-seconds={}
# poll-time-idle-seconds={}
# poll-time-window-seconds={}
# Add as many filters as needed. The first matching filter stops the replacement.
# There should be at least 1 match field, and at least 1 replace field.
# Matches are case sensitive regular expressions between implici ^ and $, e.g.
# - "." matches 1 any character
# - ".*" matches any number of any characters
# - ".+" matches 1 or more any characters.
# - "word" is an exact match.
# [[awatcher.filters]]
# match-app-id = "navigator"
# match-title = ".*Firefox.*"
# replace-app-id = "firefox"
# replace-title = "Unknown"
# Use captures for app-id or title in the regular form to use parts of the original text
# (parentheses for a capture, $1, $2 etc for each capture).
# The example rule removes the changed file indicator from the title in Visual Studio Code:
# "● file_config.rs - awatcher - Visual Studio Code" to "file_config.rs - awatcher - Visual Studio Code".
# [[awatcher.filters]]
# match-app-id = "code"
# match-title = "● (.*)"
# replace-title = "$1"
"#,
defaults::port(),
defaults::host(),
defaults::idle_timeout_seconds(),
defaults::poll_time_idle_seconds(),
defaults::poll_time_window_seconds(),
)
}
#[derive(Deserialize, DefaultFromSerde)]
pub struct ServerConfig {
#[serde(default = "defaults::port")]
pub port: u16,
#[serde(default = "defaults::host")]
pub host: String,
}
#[derive(Deserialize, DefaultFromSerde)]
#[serde(rename_all = "kebab-case")]
pub struct ClientConfig {
#[serde(default = "defaults::idle_timeout_seconds")]
pub idle_timeout_seconds: u32,
#[serde(default = "defaults::poll_time_idle_seconds")]
pub poll_time_idle_seconds: u32,
#[serde(default = "defaults::poll_time_window_seconds")]
pub poll_time_window_seconds: u32,
#[serde(default)]
pub filters: Vec<Filter>,
}
impl ClientConfig {
pub fn get_idle_timeout(&self) -> TimeDelta {
TimeDelta::seconds(self.idle_timeout_seconds.into())
}
pub fn get_poll_time_idle(&self) -> TimeDelta {
TimeDelta::seconds(self.poll_time_idle_seconds.into())
}
pub fn get_poll_time_window(&self) -> TimeDelta {
TimeDelta::seconds(self.poll_time_window_seconds.into())
}
}
#[derive(Deserialize, Default)]
pub struct FileConfig {
#[serde(default)]
pub server: ServerConfig,
#[serde(default)]
#[serde(rename = "awatcher")]
pub client: ClientConfig,
#[serde(default)]
pub config_file: PathBuf,
}
impl FileConfig {
pub fn new(config_override: Option<PathBuf>) -> anyhow::Result<Self> {
let is_config_overridden = config_override.is_some();
let config_path = if let Some(config_override) = config_override {
if config_override.starts_with("~/") {
dirs::home_dir()
.ok_or(anyhow!("Home directory is not found"))?
.join(config_override.strip_prefix("~").unwrap())
} else {
config_override
}
} else {
let mut system_config_path: PathBuf =
dirs::config_dir().ok_or(anyhow!("Config directory is unknown"))?;
system_config_path.push("awatcher");
system_config_path.push("config.toml");
system_config_path
};
let mut config = if fs::metadata(&config_path).is_ok() {
debug!("Reading config at {}", config_path.display());
let config_content = std::fs::read_to_string(&config_path).with_context(|| {
format!("Impossible to read config file {}", config_path.display())
})?;
toml::from_str(&config_content)?
} else {
if is_config_overridden {
anyhow::bail!("Config file is not accessible at {}", config_path.display());
}
let config = default_config();
let error = std::fs::create_dir(config_path.parent().unwrap());
if let Err(e) = error {
if e.kind() != ErrorKind::AlreadyExists {
Err(e)?;
}
}
debug!("Creading config at {}", config_path.display());
std::fs::write(&config_path, config)?;
Self::default()
};
config.config_file = config_path;
Ok(config)
}
}
#[cfg(test)]
mod tests {
use crate::config::FilterResult;
use super::*;
use rstest::rstest;
use std::io::Write;
use tempfile::NamedTempFile;
#[rstest]
fn all() {
let mut file = NamedTempFile::new().unwrap();
write!(
file,
r#"
[server]
port = 1234
host = "http://address.com"
[awatcher]
idle-timeout-seconds=14
poll-time-idle-seconds=13
poll-time-window-seconds=12
# Add as many filters as needed.
# There should be at least 1 match field, and at least 1 replace field.
[[awatcher.filters]]
match-app-id = "firefox"
replace-title = "Unknown"
[[awatcher.filters]]
match-app-id = "code"
match-title = "title"
replace-app-id = "VSCode"
replace-title = "Title"
"#
)
.unwrap();
let config = FileConfig::new(Some(file.path().to_path_buf())).unwrap();
assert_eq!(1234, config.server.port);
assert_eq!("http://address.com", config.server.host);
assert_eq!(14, config.client.idle_timeout_seconds);
assert_eq!(13, config.client.poll_time_idle_seconds);
assert_eq!(12, config.client.poll_time_window_seconds);
assert_eq!(2, config.client.filters.len());
let replacement1 = config.client.filters[0].apply("firefox", "any");
assert!(matches!(replacement1, FilterResult::Replace(ref r) if
r.replace_app_id.is_none() &&
r.replace_title == Some("Unknown".to_string())
));
let replacement2 = config.client.filters[1].apply("code", "title");
assert!(matches!(replacement2, FilterResult::Replace(ref r) if
r.replace_app_id == Some("VSCode".to_string()) &&
r.replace_title == Some("Title".to_string())
));
}
#[rstest]
fn match_filter() {
let mut file = NamedTempFile::new().unwrap();
write!(
file,
r#"
[[awatcher.filters]]
match-app-id = "firefox"
"#
)
.unwrap();
let config = FileConfig::new(Some(file.path().to_path_buf())).unwrap();
assert_eq!(1, config.client.filters.len());
let replacement1 = config.client.filters[0].apply("firefox", "any");
assert!(matches!(replacement1, FilterResult::Match));
}
#[rstest]
fn empty() {
let mut file = NamedTempFile::new().unwrap();
write!(file, "[awatcher]").unwrap();
let config = FileConfig::new(Some(file.path().to_path_buf())).unwrap();
assert_eq!(defaults::port(), config.server.port);
assert_eq!(defaults::host(), config.server.host);
assert_eq!(
defaults::idle_timeout_seconds(),
config.client.idle_timeout_seconds
);
assert_eq!(
defaults::poll_time_idle_seconds(),
config.client.poll_time_idle_seconds
);
assert_eq!(
defaults::poll_time_window_seconds(),
config.client.poll_time_window_seconds
);
assert_eq!(0, config.client.filters.len());
}
#[rstest]
fn wrong_file() {
let file = PathBuf::new();
let config = FileConfig::new(Some(file));
assert!(config.is_err());
assert_eq!(
"Config file is not accessible at ",
config.err().unwrap().to_string()
);
}
}