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, } 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) -> anyhow::Result { 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() ); } }