diff --git a/src/cli.rs b/src/cli.rs index ff0232f..5e86e6f 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -25,7 +25,7 @@ pub struct Cli { pub enum SubCommand { /// Start reaction daemon Start { - /// configuration file in json, jsonnet or yaml format. required. + /// configuration file in json, jsonnet or yaml format, or directory containing those files. required. #[clap(short = 'c', long)] config: PathBuf, @@ -90,7 +90,7 @@ Then prints the flushed matches and actions." Giving a configuration file permits to use its patterns in REGEX." )] TestRegex { - /// configuration file in json, jsonnet or yaml format. required. + /// configuration file in json, jsonnet or yaml format, or directory containing those files. required. #[clap(short = 'c', long)] config: PathBuf, @@ -102,6 +102,19 @@ Giving a configuration file permits to use its patterns in REGEX." #[clap(value_name = "LINE")] line: Option, }, + TestConfig { + /// configuration file in json, jsonnet or yaml format, or directory containing those files. required. + #[clap(short = 'c', long)] + config: PathBuf, + + /// how to format output: json or yaml. + #[clap(short = 'f', long, default_value_t = Format::YAML)] + format: Format, + + /// whether to output additional information + #[clap(short = 'v', long, default_value_t = false)] + verbose: bool, + }, } // Enums diff --git a/src/client/mod.rs b/src/client/mod.rs index 8565a55..6db6585 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -1,5 +1,7 @@ mod show_flush; +mod test_config; mod test_regex; pub use show_flush::request; +pub use test_config::test_config; pub use test_regex::test_regex; diff --git a/src/client/test_config.rs b/src/client/test_config.rs new file mode 100644 index 0000000..3454478 --- /dev/null +++ b/src/client/test_config.rs @@ -0,0 +1,42 @@ +use std::{error::Error, path::PathBuf}; + +use crate::{cli::Format, concepts::Config}; + +pub fn test_config( + config_path: PathBuf, + format: Format, + verbose: bool, +) -> Result<(), Box> { + let (mut cfg, cfg_files) = Config::from_path_raw(&config_path)?; + if verbose { + if config_path.is_dir() { + println!( + "Loaded the configuration from the following files in the directory {} in this order:", + config_path.display() + ); + println!("{}\n", cfg_files.join("\n")); + } else { + println!( + "Loaded the configuration from the file {}", + config_path.display() + ); + } + } + + // first serialize the raw config (before regexes are transformed and their original version + // discarded) + let cfg_str = match format { + Format::JSON => serde_json::to_string_pretty(&cfg).map_err(|e| e.to_string()), + Format::YAML => serde_yaml::to_string(&cfg).map_err(|e| e.to_string()), + } + .map_err(|e| format!("Error serializing back the configuration: {e}"))?; + + // then try to finalize the configuration: will raise an error on an invalid config + cfg.setup() + .map_err(|e| format!("Configuration file {}: {}", config_path.display(), e))?; + + // only print the serialized config if everyting went well + println!("{cfg_str}"); + + Ok(()) +} diff --git a/src/concepts/action.rs b/src/concepts/action.rs index d11462c..51f80e8 100644 --- a/src/concepts/action.rs +++ b/src/concepts/action.rs @@ -2,23 +2,28 @@ use std::{cmp::Ordering, collections::BTreeSet, fmt::Display, sync::Arc}; use chrono::TimeDelta; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use tokio::process::Command; -use super::parse_duration; +use super::parse_duration::*; use super::{Match, Pattern}; -#[derive(Clone, Debug, Default, Deserialize)] +#[derive(Clone, Debug, Default, Deserialize , Serialize)] #[serde(deny_unknown_fields)] pub struct Action { cmd: Vec, // TODO one shot time deserialization + #[serde(skip_serializing_if = "Option::is_none")] after: Option, #[serde(skip)] after_duration: Option, - #[serde(rename = "onexit", default = "set_false")] + #[serde( + rename = "onexit", + default = "set_false", + skip_serializing_if = "is_false" + )] on_exit: bool, #[serde(skip)] @@ -35,6 +40,10 @@ fn set_false() -> bool { false } +fn is_false(b: &bool) -> bool { + !*b +} + impl Action { pub fn name(&self) -> &str { &self.name diff --git a/src/concepts/config.rs b/src/concepts/config.rs index 68e3de8..415dcac 100644 --- a/src/concepts/config.rs +++ b/src/concepts/config.rs @@ -7,7 +7,7 @@ use std::{ sync::Arc, }; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use thiserror::Error; use tracing::{debug, error, info, warn}; @@ -15,7 +15,7 @@ use super::{Filter, Pattern, Stream}; pub type Patterns = BTreeMap>; -#[derive(Clone, Debug, Deserialize)] +#[derive(Clone, Debug, Deserialize, Serialize)] #[cfg_attr(test, derive(Default))] #[serde(deny_unknown_fields)] pub struct Config { @@ -28,12 +28,12 @@ pub struct Config { #[serde(default = "num_cpus::get")] concurrency: usize, - #[serde(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] start: Vec>, - #[serde(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] stop: Vec>, - #[serde(default = "dot")] + #[serde(default = "dot", skip_serializing_if = "String::is_empty")] state_directory: String, // This field only serve the purpose of having a top-level place for saving YAML variables @@ -146,20 +146,22 @@ impl Config { run_commands(&self.stop, "stop") } - pub fn from_path(path: &Path) -> Result<(Self, Vec), String> { + pub fn from_path_raw(path: &Path) -> Result<(Self, Vec), String> { match std::fs::metadata(path) { Err(e) => Err(format!("Error accessing {}: {e}", path.to_string_lossy())), Ok(m) => { if m.is_file() { - Self::from_file(path).map(|cfg| { - let fname = path - .file_name() - .map(|s| s.to_string_lossy().to_string()) - .unwrap_or("".to_string()); - (cfg, vec![fname]) - }) + Self::_from_file_raw(path) + .map(|cfg| { + let fname = path + .file_name() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or("".to_string()); + (cfg, vec![fname]) + }) + .map_err(|e| format!("Configuration file {}: {}", path.display(), e)) } else if m.is_dir() { - Self::from_dir(path) + Self::_from_dir_raw(path) } else { Err(format!( "Invalid file type for {}: not a file nor a directory", @@ -170,7 +172,27 @@ impl Config { } } - pub fn from_dir(path: &Path) -> Result<(Self, Vec), String> { + pub fn from_path(path: &Path) -> Result<(Self, Vec), String> { + match Self::from_path_raw(path) { + Ok((mut cfg, files)) => { + cfg.setup().map_err(ConfigError::BadConfig).map_err(|e| { + if path.is_dir() { + format!( + "{e}\nWhile reading config from {}. List of files read, in that order:\n{}", + path.display(), + files.join("\n"), + ) + } else { + format!("{e}\nWhile reading config from {}.", path.display()) + } + })?; + Ok((cfg, files)) + } + Err(e) => Err(e), + } + } + + fn _from_dir_raw(path: &Path) -> Result<(Self, Vec), String> { let dir = std::fs::read_dir(path) .map_err(|e| format!("Error accessing directory {}: {e}", path.display()))?; let mut cfg: Option = None; @@ -246,14 +268,7 @@ impl Config { } } - if let Some(mut cfg) = cfg { - cfg.setup().map_err(ConfigError::BadConfig).map_err(|e| { - format!( - "{e}\nWhile reading files from {}. List of files read, in that order:\n{}", - path.display(), - read_cfg_fname.join("\n"), - ) - })?; + if let Some(cfg) = cfg { Ok((cfg, read_cfg_fname)) } else { Err(format!( @@ -264,8 +279,11 @@ impl Config { } pub fn from_file(path: &Path) -> Result { - Config::_from_file(path) - .map_err(|err| format!("Configuration file {}: {}", path.display(), err)) + match Self::_from_file_raw(path) { + Ok(mut cfg) => cfg.setup().map(|_| cfg).map_err(ConfigError::BadConfig), + Err(e) => Err(e), + } + .map_err(|err| format!("Configuration file {}: {}", path.display(), err)) } fn _extension_to_format(extension: &str) -> Result { @@ -289,7 +307,7 @@ impl Config { Ok(cfg) } - fn _from_file(path: &Path) -> Result { + fn _from_file_raw(path: &Path) -> Result { let extension = path .extension() .and_then(|ex| ex.to_str()) @@ -297,11 +315,7 @@ impl Config { let format = Self::_extension_to_format(extension)?; - let mut config: Config = Self::_load_file(path, format)?; - - config.setup().map_err(ConfigError::BadConfig)?; - - Ok(config) + Self::_load_file(path, format) } } diff --git a/src/concepts/filter.rs b/src/concepts/filter.rs index 57d4c67..08b3b5a 100644 --- a/src/concepts/filter.rs +++ b/src/concepts/filter.rs @@ -8,7 +8,7 @@ use std::{ use chrono::TimeDelta; use regex::Regex; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use tracing::info; use super::parse_duration; @@ -16,7 +16,7 @@ use super::{Action, Match, Pattern, Patterns}; // Only names are serialized // Only computed fields are not deserialized -#[derive(Clone, Debug, Default, Deserialize)] +#[derive(Clone, Debug, Default, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct Filter { actions: BTreeMap, @@ -31,8 +31,9 @@ pub struct Filter { #[serde(skip)] patterns: Arc>>, + #[serde(skip_serializing_if = "Option::is_none")] retry: Option, - #[serde(rename = "retryperiod")] + #[serde(rename = "retryperiod", skip_serializing_if = "Option::is_none")] retry_period: Option, #[serde(skip)] retry_duration: Option, @@ -44,6 +45,29 @@ pub struct Filter { } impl Filter { + #[cfg(test)] + pub fn from_name(stream_name: &str, filter_name: &str) -> Filter { + Filter { + stream_name: stream_name.into(), + name: filter_name.into(), + ..Filter::default() + } + } + + #[cfg(test)] + pub fn from_name_and_patterns( + stream_name: &str, + filter_name: &str, + patterns: Vec, + ) -> Filter { + Filter { + stream_name: stream_name.into(), + name: filter_name.into(), + patterns: Arc::new(patterns.into_iter().map(|p| Arc::new(p)).collect()), + ..Filter::default() + } + } + pub fn name(&self) -> &str { &self.name } diff --git a/src/concepts/pattern.rs b/src/concepts/pattern.rs index 38aae5b..53acbb7 100644 --- a/src/concepts/pattern.rs +++ b/src/concepts/pattern.rs @@ -1,18 +1,18 @@ use std::cmp::Ordering; use regex::Regex; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; -#[derive(Clone, Debug, Deserialize)] +#[derive(Clone, Debug, Deserialize, Serialize)] #[cfg_attr(test, derive(Default))] #[serde(deny_unknown_fields)] pub struct Pattern { pub regex: String, - #[serde(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] ignore: Vec, - #[serde(default, rename = "ignoreregex")] + #[serde(default, rename = "ignoreregex", skip_serializing_if = "Vec::is_empty")] ignore_regex: Vec, #[serde(skip)] compiled_ignore_regex: Vec, diff --git a/src/concepts/stream.rs b/src/concepts/stream.rs index 67e2bfe..907d171 100644 --- a/src/concepts/stream.rs +++ b/src/concepts/stream.rs @@ -1,10 +1,10 @@ use std::{cmp::Ordering, collections::BTreeMap, hash::Hash}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use super::{Filter, Patterns}; -#[derive(Clone, Debug, Deserialize)] +#[derive(Clone, Debug, Deserialize, Serialize)] #[cfg_attr(test, derive(Default))] #[serde(deny_unknown_fields)] pub struct Stream { diff --git a/src/main.rs b/src/main.rs index 923588a..7379fa9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,7 +3,7 @@ use std::{io::IsTerminal, process::exit}; use clap::Parser; use reaction::{ cli::{Cli, SubCommand}, - client::{request, test_regex}, + client::{request, test_config, test_regex}, daemon::daemon, protocol::Order, }; @@ -72,6 +72,11 @@ async fn main() { regex, line, } => test_regex(config, regex, line), + SubCommand::TestConfig { + config, + format, + verbose, + } => test_config(config, format, verbose), }; match result { Ok(()) => {